<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rao Asad</title>
    <description>The latest articles on DEV Community by Rao Asad (@rao_asad_fb657aebdb761ad4).</description>
    <link>https://dev.to/rao_asad_fb657aebdb761ad4</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4166165%2F42ed3b35-e2df-4ac4-92b1-cc1157048356.jpeg</url>
      <title>DEV Community: Rao Asad</title>
      <link>https://dev.to/rao_asad_fb657aebdb761ad4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rao_asad_fb657aebdb761ad4"/>
    <language>en</language>
    <item>
      <title>5 Android bugs my tests didn't catch while shipping an expense tracker</title>
      <dc:creator>Rao Asad</dc:creator>
      <pubDate>Tue, 06 Oct 2026 10:58:27 +0000</pubDate>
      <link>https://dev.to/rao_asad_fb657aebdb761ad4/5-android-bugs-my-tests-didnt-catch-while-shipping-an-expense-tracker-131h</link>
      <guid>https://dev.to/rao_asad_fb657aebdb761ad4/5-android-bugs-my-tests-didnt-catch-while-shipping-an-expense-tracker-131h</guid>
      <description>&lt;p&gt;I've spent the last few months building and shipping &lt;a href="https://coinly.truetechsol.org" rel="noopener noreferrer"&gt;Coinly&lt;/a&gt;, an expense tracker for Android. It's a normal Kotlin app: MVVM, coroutines and Flow, Room, Gson, R8 in release.&lt;/p&gt;

&lt;p&gt;It also had a reasonable test suite. None of these five bugs failed a single test. Every one of them reached a real build, and a couple of them reached real users.&lt;/p&gt;

&lt;p&gt;Here they are, with the fix for each, in case they save you an evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Gson + R8: fields that silently come back as defaults
&lt;/h2&gt;

&lt;p&gt;Exchange rates stopped working in the first release build. No crash, no error. Rates just came back empty, and Drive backup quietly decided there was no existing backup to restore.&lt;/p&gt;

&lt;p&gt;The cause: when a field has no &lt;code&gt;@SerializedName&lt;/code&gt;, Gson matches JSON keys &lt;strong&gt;by field name&lt;/strong&gt;. R8 renames fields in release builds. So the JSON key &lt;code&gt;rates&lt;/code&gt; no longer matched a field called &lt;code&gt;rates&lt;/code&gt;, because that field was now called &lt;code&gt;a&lt;/code&gt;. Gson didn't complain. It left the field at its default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Works in debug, silently broken in release&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;RatesResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;emptyMap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Works in both&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;RatesResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@SerializedName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@SerializedName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rates"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;emptyMap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things made this nasty:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Debug builds aren't minified&lt;/strong&gt;, so every test on my phone passed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default values turn a crash into a wrong answer.&lt;/strong&gt; &lt;code&gt;emptyList()&lt;/code&gt; for "files in Drive" reads as "no backup exists", so a release build would have happily written a second backup next to the one it couldn't see.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;-keep&lt;/code&gt; rule alone isn't enough either: it stops the class from being removed, but its fields can still be renamed. I now put &lt;code&gt;@SerializedName&lt;/code&gt; on every field of every Gson class, including the ones whose Kotlin name already matches the key. Those are exactly the ones people leave bare.&lt;/p&gt;

&lt;p&gt;The general lesson: a release smoke test that checks "the app opens and the screens render" proves very little. R8 breaks data paths, not layouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;StateFlow.value&lt;/code&gt; that is forever the initial value
&lt;/h2&gt;

&lt;p&gt;This one showed up three separate times.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StateFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Period&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observeHistory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stateIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewModelScope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SharingStarted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WhileSubscribed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onDeleteClicked&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nf"&gt;showTwoWayDialog&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;showSimpleDialog&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WhileSubscribed&lt;/code&gt; only starts the upstream flow when something collects it. Nothing collected &lt;code&gt;history&lt;/code&gt;. It was only ever read as &lt;code&gt;.value&lt;/code&gt; from a click handler. So &lt;code&gt;.value&lt;/code&gt; was always &lt;code&gt;emptyList()&lt;/code&gt;, and a budget with three months of history reported "no history".&lt;/p&gt;

&lt;p&gt;The worst instance was on the Delete Account dialog: a user who was connected to Google Drive was told "you're not connected, so nothing is stored outside this device". The deletion itself was correct; the sentence was false.&lt;/p&gt;

&lt;p&gt;The rule I use now: &lt;strong&gt;if a StateFlow is only read through &lt;code&gt;.value&lt;/code&gt; and never collected, it must be &lt;code&gt;SharingStarted.Eagerly&lt;/code&gt;.&lt;/strong&gt; To audit, grep every &lt;code&gt;stateIn(... WhileSubscribed ...)&lt;/code&gt; for &lt;code&gt;.collect&lt;/code&gt;. If there are zero collectors and any &lt;code&gt;.value&lt;/code&gt; reads, that's the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The splash screen that hung forever
&lt;/h2&gt;

&lt;p&gt;For my first five releases, Coinly could get stuck on its splash screen until you force-stopped it. I never saw it in testing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ViewModel&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;_destination&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MutableSharedFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Destination&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// replay = 0&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;destination&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_destination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asSharedFlow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// Activity&lt;/span&gt;
&lt;span class="n"&gt;lifecycleScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;repeatOnLifecycle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Lifecycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STARTED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;repeatOnLifecycle(STARTED)&lt;/code&gt; cancels the collector whenever the activity is stopped. A &lt;code&gt;SharedFlow&lt;/code&gt; with &lt;code&gt;replay = 0&lt;/code&gt; drops anything it can't deliver right now. So if the ViewModel decided where to go while the app wasn't in the foreground, the event was gone, and the splash waited for a second event that never came.&lt;/p&gt;

&lt;p&gt;That sounds rare until you notice how often it happens: tapping a notification on a locked phone, opening the app during a call, switching away during the splash animation. Coinly's daily reminder opens the splash screen, so this was on a real path.&lt;/p&gt;

&lt;p&gt;You can reproduce it deterministically with adb:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell input keyevent 26        &lt;span class="c"&gt;# screen off&lt;/span&gt;
adb shell am start &lt;span class="nt"&gt;-n&lt;/span&gt; com.example/.SplashActivity
&lt;span class="nb"&gt;sleep &lt;/span&gt;8                            &lt;span class="c"&gt;# the ViewModel emits with no collector&lt;/span&gt;
adb shell input keyevent 224       &lt;span class="c"&gt;# wake, then unlock and look&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is one argument: &lt;code&gt;MutableSharedFlow&amp;lt;Destination&amp;gt;(replay = 1)&lt;/code&gt;. A late collector still gets the value, and since the splash finishes itself after navigating, there's nothing to re-navigate. Leave a comment next to it, because &lt;code&gt;replay = 1&lt;/code&gt; on a "one-shot event" looks wrong and someone will revert it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The in-app language picker that worked everywhere except Google Play
&lt;/h2&gt;

&lt;p&gt;Coinly ships in English, Spanish, Portuguese and Indonesian, with a language picker inside the app. On my test phone it worked perfectly. For people who installed from Play and picked a language other than their phone's, it showed English text with, say, Spanish number formatting.&lt;/p&gt;

&lt;p&gt;The reason: with an Android App Bundle, &lt;strong&gt;Play only installs the language resources that match the phone's system languages&lt;/strong&gt;. My test installs were APKs, which carry every language, so the bug couldn't happen on my device.&lt;/p&gt;

&lt;p&gt;The fix is in &lt;code&gt;build.gradle.kts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;android&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;bundle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;language&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// The app has its own language picker, so every language must be installed.&lt;/span&gt;
            &lt;span class="n"&gt;enableSplit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alternative is downloading languages on demand with Play Core. I didn't find this bug by testing. Lint found it (&lt;code&gt;AppBundleLocaleChanges&lt;/code&gt;). If you have an in-app language picker, check your bundle config today.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A crash on a device nobody owns
&lt;/h2&gt;

&lt;p&gt;Crashlytics reported an &lt;code&gt;InflateException&lt;/code&gt; on the onboarding screen, caused by &lt;code&gt;NoSuchMethodError&lt;/code&gt; in Material's &lt;code&gt;FocusRingDrawable&lt;/code&gt;. The device was &lt;code&gt;sdk_gphone_arm64&lt;/code&gt; on Android 11.&lt;/p&gt;

&lt;p&gt;That's not a user. It's Google Play's pre-launch test robot, which crawls every new release. It loads its own un-shrunk copy of Material inside your app's process, and R8 had renamed a method that the robot's copy expected by its original name. Real users weren't affected, but a crash in pre-launch reports is noise you don't want, and it can hide a real one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-keep class com.google.android.material.focus.** { *; }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I got the same crash in my second app a few days later, from a slider this time, and widened the rule for that app.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in how I work
&lt;/h2&gt;

&lt;p&gt;None of these were exotic. They were all "the build I test isn't the build people install":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test a minified release build&lt;/strong&gt;, not just debug, and exercise the network and storage paths, not just the screens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Install from Play&lt;/strong&gt; (internal testing track) at least once per release. APKs hide bundle problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with the app not in the foreground&lt;/strong&gt;: locked screen, notification taps, switching away mid-launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read Crashlytics device names&lt;/strong&gt; before panicking. &lt;code&gt;sdk_gphone&lt;/code&gt; is a robot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Coinly is free on &lt;a href="https://play.google.com/store/apps/details?id=org.truetechsol.coinly" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt; if you want to see the app these came out of. I'd like to hear the bug your tests missed.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>debugging</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
