<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="../assets/xml/rss.xsl" media="all"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>TinyComputers.io (Posts about jetpack compose)</title><link>https://tinycomputers.io/</link><description></description><atom:link href="https://tinycomputers.io/categories/jetpack-compose.xml" rel="self" type="application/rss+xml"></atom:link><language>en</language><copyright>Original site content © 2022–2026 Tiny Machines Workshop, LLC, except where otherwise noted. Some rights reserved.</copyright><lastBuildDate>Mon, 12 Oct 2026 21:28:23 GMT</lastBuildDate><generator>Nikola (getnikola.com)</generator><docs>http://blogs.law.harvard.edu/tech/rss</docs><item><title>Ballistics Insight on Android: A Solver That Admits What It Assumed</title><link>https://tinycomputers.io/posts/ballistics-insight-on-android-a-solver-that-admits-what-it-assumed.html?utm_source=feed&amp;utm_medium=rss&amp;utm_campaign=rss</link><dc:creator>A.C. Jokela</dc:creator><description>&lt;div class="audio-widget"&gt;
&lt;div class="audio-widget-header"&gt;
&lt;span class="audio-widget-icon"&gt;🎧&lt;/span&gt;
&lt;span class="audio-widget-label"&gt;Listen to this article&lt;/span&gt;
&lt;/div&gt;
&lt;audio controls preload="metadata"&gt;
&lt;source src="https://tinycomputers.io/ballistics-insight-on-android-a-solver-that-admits-what-it-assumed_tts.mp3" type="audio/mpeg"&gt;
&lt;/source&gt;&lt;/audio&gt;
&lt;div class="audio-widget-footer"&gt;27 min · AI-generated narration&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Ballistics Insight is on &lt;a href="https://play.google.com/store/apps/details?id=io.tinymachines.ballisticsinsight"&gt;Google Play&lt;/a&gt;. It is a long-range ballistic calculator: give it a load, a rifle and the day's conditions, and it gives you the drop and the wind hold at every distance in MIL or MOA, with time of flight, velocity and energy. The solver is &lt;a href="https://github.com/ajokela/ballistics-engine"&gt;ballistics-engine&lt;/a&gt;, the Rust library I &lt;a href="https://tinycomputers.io/posts/open-sourcing-a-high-performance-rust-based-ballistics-engine.html"&gt;open-sourced last year&lt;/a&gt; and wrote about &lt;a href="https://tinycomputers.io/posts/the-bullet-doesnt-go-where-you-point-it.html"&gt;testing&lt;/a&gt; in August. On the phone it runs entirely on the device. A rifle range is a bad place to depend on a cell signal, so nothing about a solve touches the network.&lt;/p&gt;
&lt;p&gt;The Android repository's first commit is from August 16, 2025. It has 560 commits now, 217 Kotlin source files and about 57,800 lines, and the build I submitted on October 8 ran 1,982 unit tests and 167 on-device tests without a failure. Most of those tests exist because something went wrong first. This post is about how the app is put together and about the mistakes that shaped it, because nearly all of them reduce to one lesson. The worst failure a ballistic calculator can have is not a crash. It is a confident number built on an assumption the shooter never saw.&lt;/p&gt;
&lt;h3&gt;One JNI function and a JSON envelope&lt;/h3&gt;
&lt;p&gt;The app is Kotlin and Jetpack Compose with Material 3: five tabs on a phone (Solve, DOPE, Loads, Tools, Account) and a navigation rail on a tablet. The physics is a prebuilt Rust shared library, &lt;code&gt;libballistics_engine.so&lt;/code&gt;, about 5 MB for each of the two ABIs the app ships, &lt;code&gt;arm64-v8a&lt;/code&gt; and &lt;code&gt;x86_64&lt;/code&gt;. Between Kotlin and Rust sits a 612-line C++ shim.&lt;/p&gt;
&lt;p&gt;The first version of that shim did what JNI shims usually do. Every engine function got a native method, a C struct, and hand-written marshalling for each field. That holds up until the engine grows. Each new feature meant touching Rust, C, C++ and Kotlin in lockstep, and a field added on one side and forgotten on another does not fail loudly: three doubles still come back, they are just the wrong three doubles. So in August I replaced the pattern with a single entry point. The engine exports &lt;code&gt;ballistics_bridge_call_n&lt;/code&gt;, which takes a JSON envelope naming a command and returns a JSON envelope holding either a result or an error. On the Kotlin side the whole bridge is one function:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="kd"&gt;fun&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;callEnvelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;JsonElement? &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Envelope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;envelope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;buildJsonObject&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api_version"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;API_VERSION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;raw&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;nativeCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;response&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseToJsonElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="na"&gt;jsonObject&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="o"&gt;]?.&lt;/span&gt;&lt;span class="na"&gt;jsonPrimitive&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;booleanOrNullSafe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;// ...a false "ok" becomes a BridgeException carrying the engine's error code&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The app now uses sixteen commands through that one function: &lt;code&gt;solve&lt;/code&gt;; &lt;code&gt;card.range_table&lt;/code&gt;, &lt;code&gt;card.come_ups&lt;/code&gt;, &lt;code&gt;card.wind&lt;/code&gt; and &lt;code&gt;card.pdf&lt;/code&gt; for range cards; &lt;code&gt;true.fit&lt;/code&gt; and &lt;code&gt;true.wind&lt;/code&gt; for truing; &lt;code&gt;bc5d.info&lt;/code&gt; for correction tables; three &lt;code&gt;profile.*&lt;/code&gt; commands for importing profiles from other solvers; three &lt;code&gt;reticle.*&lt;/code&gt; commands for holds read off a reticle; and two &lt;code&gt;meta.*&lt;/code&gt; queries. A new engine capability is a new command name, and the C++ does not change. Serializing JSON costs microseconds next to a trajectory integration, so I have not missed the typed interface once. The four original typed C functions are still in the library, and only the shot-spread screen still calls them.&lt;/p&gt;
&lt;p&gt;The other half of the integration is knowing exactly which engine shipped. A file called &lt;code&gt;engine.ref&lt;/code&gt; holds the commit the app is pinned to, currently the v0.43.0 tag from September 17. A build script checks that commit out into a throwaway git worktree and runs the engine's own Android build with cargo-ndk, the &lt;code&gt;bridge&lt;/code&gt;, &lt;code&gt;ffi&lt;/code&gt;, &lt;code&gt;pdf&lt;/code&gt; and &lt;code&gt;profile-import&lt;/code&gt; features, and 16 KB page alignment, which Google Play now requires of native code. A CI job then reads the version string out of the committed &lt;code&gt;.so&lt;/code&gt; and checks it against the pin and against the list of bridge commands the Kotlin calls. That job exists because the pin and the binary had drifted apart once already, and nothing noticed.&lt;/p&gt;
&lt;h3&gt;The rule: say what you assumed&lt;/h3&gt;
&lt;p&gt;Ballistics punishes defaults. A solver needs air pressure, temperature, humidity, the barrel's twist rate and, for Coriolis, your latitude. People leave fields blank, and every blank has an obvious default. Every default is also a claim about the world that the shooter did not make.&lt;/p&gt;
&lt;div style="text-align: center; margin: 2rem 0;"&gt;
&lt;img src="https://tinycomputers.io/images/ballistics-insight-android/solve-results.png" alt="The Solve tab in Ballistics Insight showing Trajectory Results at 1000 yards: impact distance 1000.0 yards, max height 1.85 inches, time of flight 1.45 seconds, impact velocity 1590 fps, impact energy 785 ft-lbs. Above the results two notes read: Pressure 25.65 inHg, standard atmosphere for 4200 ft, not measured; and Humidity 50% assumed, not measured." width="720" height="1280" loading="lazy" style="width: 320px; max-width: 100%; height: auto; border-radius: 6px; border: 1px solid #ccc;"&gt;
&lt;/div&gt;

&lt;p&gt;&lt;em&gt;The Solve tab at 1,000 yards. The two lines above the results say which inputs were assumed, and what was assumed.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Pressure is the clearest case. Early versions filled a blank pressure field by computing a standard-atmosphere value in Kotlin and sending it to the engine. The engine had no way to tell that number from one a Kestrel had measured, so it had nothing to report. Now a blank pressure is simply left out of the request, and the engine resolves the ICAO standard atmosphere at the given altitude and says that it did. The comment in the request builder is the whole design:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="c1"&gt;// ABSENT when nothing was measured. The engine resolves ICAO&lt;/span&gt;
&lt;span class="c1"&gt;// standard pressure at `altitude_m` and reports that it did; a&lt;/span&gt;
&lt;span class="c1"&gt;// number computed here would arrive indistinguishable from a&lt;/span&gt;
&lt;span class="c1"&gt;// Kestrel reading and silence the assumption.&lt;/span&gt;
&lt;span class="n"&gt;pressurePa&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;atmosphere&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pressureHpa&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;times&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PASCALS_PER_HECTOPASCAL&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;That is where "25.65 inHg, standard atmosphere for 4200 ft, not measured" comes from. When a pressure is measured, the app asks which kind it is. A Kestrel reports station pressure, the absolute pressure where you are standing. A weather app, an ATIS broadcast or a METAR reports QNH, the altimeter setting corrected to sea level. At 4,200 feet the two differ by more than four inches of mercury, and typing a QNH into a station-pressure field tells the solver you are shooting at sea level, roughly the 0.6 mil of elevation at 1,000 yards that a card bug I describe below cost. The engine reduces QNH to station pressure itself, through the troposphere layer of the standard atmosphere, so the app only has to record which one the shooter typed.&lt;/p&gt;
&lt;p&gt;The barrel twist taught me the same rule more expensively. Until September 12, every solve on both platforms assumed a 1:12 twist. The load screen stored the real one, and the field had been nullable since the day it was added, with a comment explaining why: "a silent wrong twist is worse than an empty field." Then the code that built the solver's inputs never read it. Miller stability scales with the inverse square of the twist in calibers, so a 1:8 barrel is about 2.25 times as stable as the app believed, and every faster-twist rifle was shown less spin drift than it has. Now the load's twist travels with it to the solver. When no twist is recorded, the solver skips spin drift and Magnus and the screen says so, instead of leaving a switched-on toggle quietly doing nothing.&lt;/p&gt;
&lt;p&gt;Coriolis follows the same rule. The engine computes it fully, but the Solve tab does not collect a shooting latitude yet, and I would rather the toggle say "Coriolis needs a shooting latitude, which this screen doesn't collect, so it is not applied" than invent a latitude.&lt;/p&gt;
&lt;h3&gt;Weather from the meter, not from memory&lt;/h3&gt;
&lt;p&gt;The most common source of a wrong input is typing. So the app reads two Bluetooth weather meters directly: the Kestrel 5700 LiNK and the WeatherFlow WEATHERmeter.&lt;/p&gt;
&lt;div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem; margin: 2rem 0;"&gt;
&lt;img src="https://tinycomputers.io/images/ballistics-insight-android/read-meter-chooser.png" alt="The Solve tab after tapping Read meter: a Choose a meter list offering a Kestrel, and a WEATHERmeter marked uncalibrated and recognized by its name only." width="720" height="1280" loading="lazy" style="width: 300px; max-width: 100%; height: auto; border-radius: 6px; border: 1px solid #ccc;"&gt;
&lt;img src="https://tinycomputers.io/images/ballistics-insight-android/read-meter-live.png" alt="The Solve tab reading a meter live: a Stop reading button, the note Filled from Kestrel at 8:35:32 AM, Wind is the average of the last 30 s, and fields showing 74.7 degrees F, altitude 895 feet and pressure 28.960 inHg." width="720" height="1280" loading="lazy" style="width: 300px; max-width: 100%; height: auto; border-radius: 6px; border: 1px solid #ccc;"&gt;
&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Left: choosing a meter. Right: live mode, filling the fields from a Kestrel and averaging the wind over 30 seconds.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The Kestrel's sensor characteristic packs wind speed, temperature, humidity and station pressure into little-endian 16-bit fields, each with a reserved value that means "not available":&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="kd"&gt;val&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;wind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LittleEndian&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;temperature&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LittleEndian&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;humidity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LittleEndian&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;pressure&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LittleEndian&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;KestrelSensorReading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;windSpeedMps&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NOT_AVAILABLE_U16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;wind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;temperatureC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TEMPERATURE_NOT_AVAILABLE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;OnesComplement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;i16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100.0&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;// ...humidity and pressure are decoded the same way&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The order of those checks is not cosmetic. Temperature is signed, and if you apply the sign rule before testing for the not-available marker, &lt;code&gt;0x8001&lt;/code&gt; decodes as minus 327.66 °C, a number the solver will use without complaint. Wind direction is in the frame as well, and the first version discards it and leaves direction to the wind dial on the Solve tab.&lt;/p&gt;
&lt;p&gt;The WEATHERmeter carries the word "uncalibrated" in the chooser, and the label is earned: side by side with the Kestrel it read 1.2 °C warm and 15 to 17 points high on relative humidity. The app recognizes it by its advertised name only, and says that too. With either meter it only ever subscribes to notifications and never writes to the device, and it declares its Bluetooth scan permission &lt;code&gt;neverForLocation&lt;/code&gt;, which a unit test pins. The newest build adds the phone itself as a source: its barometer, where it has one, and its GPS altitude converted to height above sea level.&lt;/p&gt;
&lt;h3&gt;Truing has to report its own error bars&lt;/h3&gt;
&lt;p&gt;Every ballistic calculator eventually disagrees with the rifle. The published ballistic coefficient was measured with someone else's barrel, and the velocity on the ammunition box is optimistic. Truing closes the gap: shoot at known distances, record where the bullet actually went, and fit the model to what happened.&lt;/p&gt;
&lt;div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem; margin: 2rem 0;"&gt;
&lt;img src="https://tinycomputers.io/images/ballistics-insight-android/truing-measured-drops.png" alt="The truing screen's Measured drops section in MIL for a 6.5 Creedmoor 140 gr ELD-M: 300 yards 1.2, 600 yards 3.9, 900 yards 7.4 and 1200 yards 11.9 mil, each with a plus-or-minus of 0.05." width="720" height="1280" loading="lazy" style="width: 300px; max-width: 100%; height: auto; border-radius: 6px; border: 1px solid #ccc;"&gt;
&lt;img src="https://tinycomputers.io/images/ballistics-insight-android/truing-fit.png" alt="The truing result, Fitted from 4 distances: muzzle velocity 2682 fps, 95% 2660 to 2703 fps, before this fit 2710 fps; ballistic coefficient 0.3006, 95% 0.2895 to 0.3117, before this fit 0.3150; an Apply to this load button; and a note that the typical miss at these distances goes from 0.36 mil to 0.01 mil." width="720" height="1280" loading="lazy" style="width: 300px; max-width: 100%; height: auto; border-radius: 6px; border: 1px solid #ccc;"&gt;
&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Four measured drops, each with its reading error, and the fit they produce: 2,682 fps instead of the 2,710 entered, and a BC of 0.3006 instead of 0.3150, each with a 95% interval.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The engine fits muzzle velocity and ballistic coefficient together. Each measured drop carries a plus-or-minus, and the app requires one, because a drop read to 0.05 mil and one estimated to half a mil should not count equally. The fit is a weighted two-parameter maximum a posteriori estimate, solved with Levenberg–Marquardt in scaled coordinates (100 fps and 0.1 of BC each count as one unit, so neither parameter dominates a step), followed by a pattern search that confirms the minimum. The 95% intervals come from a local Gaussian approximation of the posterior. The engine withholds them when the fit lands on a parameter bound or the information matrix is singular, because an interval computed there is fiction.&lt;/p&gt;
&lt;p&gt;Those intervals earned their place on the screen in an embarrassing way. The first store screenshot for this feature used three drops I picked by hand, at 400, 600 and 800 yards. The fit came back with two engine warnings, one of them that the observations "weakly constrain BC", and a BC interval of 0.2674 to 0.3254, which contained the 0.3150 the fit was supposed to be correcting. The engine was right. Over that short a spread, a slow barrel and a draggy bullet bend the trajectory in almost the same way, and no fit can tell them apart. The engine ships a planner for exactly this question, &lt;code&gt;true.plan&lt;/code&gt;, which chooses the distances that best separate the two parameters. Asked for four stations at least 300 yards apart, it chose 300, 600, 900 and 1,200, and the screenshot above is the result: both intervals now exclude the numbers they replaced.&lt;/p&gt;
&lt;p&gt;The screen is blunt about what a fit proves. Below the result it shows how much closer the model now lands at the fitted distances, then explains that a fit always improves the distances it was fitted to, "so this is not a test of whether it worked. The test is a new shot at a distance you did not fit."&lt;/p&gt;
&lt;p&gt;One sentence on the input side mattered as much as any of the maths. The engine wants each drop as a positive number below the line of sight. Enter the drops with the opposite sign and the request is still perfectly valid. It fits. It converges on a bullet that climbs, prints a confident interval around it, spends one of your five free fits and offers to write the result onto your load. Nothing below the keyboard can detect that, so the screen now states the sign convention, and a test pins the sentence word for word on both Android and iOS.&lt;/p&gt;
&lt;p&gt;The second kind of truing works from wind. Given a horizontal miss at a known distance, &lt;code&gt;true.wind&lt;/code&gt; finds the crosswind that reproduces it, after subtracting the spin drift that the load's twist predicts. It is a one-dimensional root find over plus or minus 100 mph by false position, with the Illinois modification that keeps one end of the bracket from stalling:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="c1"&gt;// Illinois: halve the retained endpoint's function value so one side cannot stall.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;low&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;f_low&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;f_high&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;high&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;f_high&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;f_low&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;With several observations the answer is an inverse-variance weighted mean, so the careful reading counts for more than the hurried one.&lt;/p&gt;
&lt;h3&gt;A DOPE card is a record, not a recipe&lt;/h3&gt;
&lt;div style="text-align: center; margin: 2rem 0;"&gt;
&lt;img src="https://tinycomputers.io/images/ballistics-insight-android/dope-range-card.png" alt="A DOPE range card in MIL from 100 to 1000 yards with columns for range, elevation, wind, velocity, energy and time; 1000 yards reads 8.3 mil elevation, 1.7 mil wind, 1590 fps, 785 ft-lb and 1.45 seconds. A legend marks supersonic, transonic and subsonic rows, and a Save this card button sits below." width="720" height="1280" loading="lazy" style="width: 320px; max-width: 100%; height: auto; border-radius: 6px; border: 1px solid #ccc;"&gt;
&lt;/div&gt;

&lt;p&gt;&lt;em&gt;A range card in MIL from 100 to 1,000 yards. Row color says whether the bullet is still supersonic.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A DOPE card is the sheet you tape to the stock or keep in a chest pocket. Once saved, it must not change because the weather changed, a correction table was updated, or I shipped a new engine. So a saved card stores the engine's request and response as raw bytes, exactly as they came back, along with the version of the engine that solved them. Those bytes go into the local database and into the cloud and come back out without being parsed and re-serialized, and tests check that at every hop. Opening a card renders those rows; nothing re-solves. "Regenerate for current conditions" exists, but it shows what changed before it writes anything. The PDF export is rendered by the engine itself, with the &lt;code&gt;printpdf&lt;/code&gt; crate, so the printed card and the screen are built from the same bytes.&lt;/p&gt;
&lt;p&gt;Getting the card right took more than one pass. One release laid six columns totalling 456 density-independent pixels into a 379 dp card, so energy and time of flight ran off the edge. Worse, the card ignored the atmosphere entirely, which at 4,200 feet is about 0.6 mil of missing elevation at 1,000 yards. Fixing that exposed a units bug of its own: the engine takes altitude in meters, and 4,200 sent raw is the atmosphere at 13,780 feet.&lt;/p&gt;
&lt;h3&gt;The bugs lived in the seams&lt;/h3&gt;
&lt;p&gt;The engine's physics has fuzzing harnesses and closed-form oracles behind it. The app's bugs were in the places those cannot reach: the joints between the engine and the app, between two devices, and between the source tree and the thing that actually shipped.&lt;/p&gt;
&lt;p&gt;In August I upgraded the engine binaries from 0.13 to 0.33. Between those versions the typed interface's axis convention changed from (lateral, vertical, downrange) to (downrange, vertical, lateral). The functions still returned three valid doubles, so no test failed. Every horizontal figure on the shot-spread screen became exactly zero. Separately, the engine marks a simulated shot that falls short of the target with a vertical position of minus a billion meters, and nothing filtered it, so the vertical standard deviation came out near 2 × 10&lt;sup&gt;10&lt;/sup&gt; inches. The fix moved the maths into a Kotlin class a unit test can reach and wrote the convention into the C header, where the next upgrade will trip over it.&lt;/p&gt;
&lt;p&gt;Sync is opt-in. Until you sign in with Google or Apple, your loads, logs and cards stay on the phone, and the sync workers skip anonymous users entirely. Once you sign in, a background job syncs every 15 minutes, and for its first weeks it corrupted data on that schedule. Android writes timestamps as epoch milliseconds, and the decoder handled ISO strings and Firestore timestamps but had no branch for a plain number. Every Android row read back as "now", the cloud copy always looked newer, last-write-wins fired on every cycle, and a G7 load on one device could turn back into G1 on another. None of it appeared with one device. All of it appeared with two, which is exactly the setup that a Pro purchase covering both platforms encourages.&lt;/p&gt;
&lt;p&gt;An internal test build once went out with the template &lt;code&gt;google-services.json&lt;/code&gt;, which contains the literal string &lt;code&gt;YOUR_API_KEY&lt;/code&gt;. The Google Services plugin only checks that the fields exist, the file is gitignored so &lt;code&gt;git status&lt;/code&gt; stayed clean, and CI copies the template on purpose, so CI could never have caught it. A tester's Account screen reading "API key not valid" did. There is now a pre-upload script that checks the configuration and the built bundle both, since a stale bundle built before a fix passes every check made against the source.&lt;/p&gt;
&lt;p&gt;And a 5911 × 3941 PNG, drawn behind the interface at 5% opacity, was 46% of the download. Re-encoded as a 2560-pixel WebP it is 291 KB, and the app bundle went from 45.1 MB to 28.1 MB.&lt;/p&gt;
&lt;h3&gt;Free solver, one-time Pro&lt;/h3&gt;
&lt;p&gt;The solver is free, with no ads and no tracking. So are the downloadable BC correction tables, which matter more than they sound. They are BC5D tables: binary cubes of correction factors indexed by drag model, bullet weight, BC, muzzle velocity and current velocity, generated from the machine-learning model I described when I wrote about &lt;a href="https://tinycomputers.io/posts/discretizing-continuous-ml-models-offline-ballistic-coefficient-corrections.html"&gt;turning it into lookup tables&lt;/a&gt; so the corrections work offline. The app downloads one table per caliber to a temporary file, checks it against the manifest's CRC-32, has the engine validate the header and confirm the caliber, and only then renames it into place. The current set covers thirteen calibers in 3.3 MB.&lt;/p&gt;
&lt;p&gt;Pro is a one-time purchase rather than a subscription. It unlocks DOPE range cards and their PDF export, importing &lt;code&gt;.a7p&lt;/code&gt; profiles from other solvers, and truing, and the first five truing fits are free without it. A calculator is a tool, and a tool should be bought once.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Package&lt;/td&gt;
&lt;td&gt;&lt;code&gt;io.tinymachines.ballisticsinsight&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version&lt;/td&gt;
&lt;td&gt;2.0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;8.0 (API 26) and up, targeting API 36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engine&lt;/td&gt;
&lt;td&gt;ballistics-engine 0.43.0, pinned by commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native code&lt;/td&gt;
&lt;td&gt;about 5 MB per ABI, arm64-v8a and x86_64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App code&lt;/td&gt;
&lt;td&gt;217 Kotlin files, about 57,800 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests at submission&lt;/td&gt;
&lt;td&gt;1,982 unit, 167 on-device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bridge commands in use&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Ballistics Insight is &lt;a href="https://play.google.com/store/apps/details?id=io.tinymachines.ballisticsinsight"&gt;on Google Play&lt;/a&gt; now, free to install. The engine underneath it is open source under MIT or Apache 2.0 &lt;a href="https://github.com/ajokela/ballistics-engine"&gt;on GitHub&lt;/a&gt;, with documentation at &lt;a href="https://ballistics.rs/"&gt;ballistics.rs&lt;/a&gt;, and the iPhone build pins the same engine commit. When I wrote about the engine in August, the point was that its hard bugs were never in the physics. The app taught me where they go instead: into whatever the software assumed while the shooter was not looking. So the rule now is that every assumption ends up on the screen.&lt;/p&gt;</description><category>android</category><category>app development</category><category>ballistics</category><category>bluetooth</category><category>google play</category><category>jetpack compose</category><category>jni</category><category>kotlin</category><category>numerical methods</category><category>open-source</category><category>physics</category><category>rust</category><guid>https://tinycomputers.io/posts/ballistics-insight-on-android-a-solver-that-admits-what-it-assumed.html</guid><pubDate>Mon, 12 Oct 2026 17:00:00 GMT</pubDate></item></channel></rss>