Xpense Dev Retrospective
Before the story: no Play Store release ever happened between the last v1.x version (1.7.2) and v2.0.0. This bug was caught during my own pre-release testing, on a real device, with my own genuine months-old expense data, before v2.0.0 ever shipped. No live user was ever exposed to this. It's still worth a full post, because it's exactly the kind of failure that would have quietly gone out otherwise.
Testing the v1.7.2 to v2.0.0 upgrade meant installing 1.7.2 fresh, using it for real, logging actual expenses over actual days, and then installing the v2.0.0 update on top of it, exactly the path a real upgrading user would take. LegacyMigration.migrateIfNeeded() was supposed to read that data and carry it into Room.
Installed the release build of v2.0.0 over the real 1.7.2 install with real data.
Opened the app. No crash. No error dialog. It just opened normally.
The dashboard showed zero expenses. Not an error state, a genuinely empty state, as if this were a brand new install.
Pulling the Room database off the device with sqlite3 confirmed it directly: the expense and category tables were empty. The migration had run, set its "migrated" flag to true, and inserted nothing.
The debug build had worked perfectly every single time it was tested. Only the minified release build failed, silently. That's the exact shape of an R8 problem, since R8 only runs its stripping and obfuscation passes on release builds.
LegacyMigration parses the old JSON using an anonymous TypeToken subclass, which is how Gson figures out that it's deserializing a List<Expense> and not just some generic list of unknown type:
val type = object : TypeToken<List<Expense>>() {}.type val legacyExpenses: List<Expense>? = try { gson.fromJson(content, type) } catch (e: Exception) { null }
Gson's generics trick depends entirely on the generic type signature of that anonymous subclass surviving into the compiled bytecode. R8, by default, was stripping that exact signature information as part of its normal shrinking pass, since from R8's point of view, an unused generic type parameter looks like dead metadata safe to remove.
With the signature gone, gson.fromJson couldn't determine the real type anymore. It didn't crash. It just failed to parse correctly, and the surrounding catch block quietly returned null, exactly as it was designed to for genuinely corrupted data. The code had no way to tell the difference between "this file is corrupted" and "R8 broke type resolution," because both look identical from inside that catch block.
Why the try/catch made this worse, not better: the catch block existed for a legitimate reason, a truly corrupted legacy file shouldn't crash the app on every launch. But swallowing the exception with no logging meant this specific failure mode produced zero evidence. Nothing in Logcat. Nothing in a crash report. Just quietly empty data and a flag saying migration had already succeeded.
Two proguard rules, telling R8 to keep exactly the generic signature information Gson depends on, while still allowing the classes themselves to be obfuscated and shrunk normally:
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken -keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
-keepattributes Signature, already present for other reasons, was necessary but not sufficient on its own. The signature attribute needs to be kept in general, and the specific TypeToken classes need to be explicitly protected from having their structure altered in a way that would still lose the generic information Gson reads at runtime.
Not just re-running the app and seeing expenses appear. A full clean 1.7.2 install, real data logged again, upgrade to the patched release build, and this time inspecting the resulting Room database directly with sqlite3 to confirm every row genuinely made it across, rather than trusting what the UI displayed.
Debug builds passing tells you the logic is right. It says nothing about whether minification will silently break an assumption that logic depends on. Anything using reflection, generics resolved at runtime, or Gson's TypeToken pattern specifically, needs to be tested as an actual release build, not just assumed safe because the debug build worked.
Storage and the migration are done. Next up, something with a much smaller blast radius but its own real subtlety, how light and dark mode actually work now.