I've spent the last few months building and shipping Coinly, an expense tracker for Android. It's a normal Kotlin app: MVVM, coroutines and Flow, Room, Gson, R8 in release.
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.
Here they are, with the fix for each, in case they save you an evening.
1. Gson + R8: fields that silently come back as defaults
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.
The cause: when a field has no @SerializedName, Gson matches JSON keys by field name. R8 renames fields in release builds. So the JSON key rates no longer matched a field called rates, because that field was now called a. Gson didn't complain. It left the field at its default.
// Works in debug, silently broken in release
data class RatesResponse(
val result: String = "",
val rates: Map<String, Double> = emptyMap()
)
// Works in both
data class RatesResponse(
@SerializedName("result") val result: String = "",
@SerializedName("rates") val rates: Map<String, Double> = emptyMap()
)
Two things made this nasty:
- Debug builds aren't minified, so every test on my phone passed.
-
Default values turn a crash into a wrong answer.
emptyList()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.
A -keep rule alone isn't enough either: it stops the class from being removed, but its fields can still be renamed. I now put @SerializedName 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.
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.
2. StateFlow.value that is forever the initial value
This one showed up three separate times.
val history: StateFlow<List<Period>> = repo.observeHistory(id)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun onDeleteClicked() {
if (history.value.isNotEmpty()) showTwoWayDialog() else showSimpleDialog()
}
WhileSubscribed only starts the upstream flow when something collects it. Nothing collected history. It was only ever read as .value from a click handler. So .value was always emptyList(), and a budget with three months of history reported "no history".
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.
The rule I use now: if a StateFlow is only read through .value and never collected, it must be SharingStarted.Eagerly. To audit, grep every stateIn(... WhileSubscribed ...) for .collect. If there are zero collectors and any .value reads, that's the bug.
3. The splash screen that hung forever
For my first five releases, Coinly could get stuck on its splash screen until you force-stopped it. I never saw it in testing.
// ViewModel
private val _destination = MutableSharedFlow<Destination>() // replay = 0
val destination = _destination.asSharedFlow()
// Activity
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.destination.collect { navigate(it) }
}
}
repeatOnLifecycle(STARTED) cancels the collector whenever the activity is stopped. A SharedFlow with replay = 0 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.
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.
You can reproduce it deterministically with adb:
adb shell input keyevent 26 # screen off
adb shell am start -n com.example/.SplashActivity
sleep 8 # the ViewModel emits with no collector
adb shell input keyevent 224 # wake, then unlock and look
The fix is one argument: MutableSharedFlow<Destination>(replay = 1). 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 replay = 1 on a "one-shot event" looks wrong and someone will revert it.
4. The in-app language picker that worked everywhere except Google Play
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.
The reason: with an Android App Bundle, Play only installs the language resources that match the phone's system languages. My test installs were APKs, which carry every language, so the bug couldn't happen on my device.
The fix is in build.gradle.kts:
android {
bundle {
language {
// The app has its own language picker, so every language must be installed.
enableSplit = false
}
}
}
The alternative is downloading languages on demand with Play Core. I didn't find this bug by testing. Lint found it (AppBundleLocaleChanges). If you have an in-app language picker, check your bundle config today.
5. A crash on a device nobody owns
Crashlytics reported an InflateException on the onboarding screen, caused by NoSuchMethodError in Material's FocusRingDrawable. The device was sdk_gphone_arm64 on Android 11.
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.
-keep class com.google.android.material.focus.** { *; }
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.
What I changed in how I work
None of these were exotic. They were all "the build I test isn't the build people install":
- Test a minified release build, not just debug, and exercise the network and storage paths, not just the screens.
- Install from Play (internal testing track) at least once per release. APKs hide bundle problems.
- Test with the app not in the foreground: locked screen, notification taps, switching away mid-launch.
-
Read Crashlytics device names before panicking.
sdk_gphoneis a robot.
Coinly is free on Google Play if you want to see the app these came out of. I'd like to hear the bug your tests missed.
Top comments (0)