An Android release build kept dying on the same NPE.
NullPointerException: Attempt to invoke virtual method
'java.lang.String ...BottomNavItem.getRoute()' on a null object referenceFrom v0.3.0 through v0.5.2. It blows up on recomposition after the first navigation or a permission grant. Debug builds are fine, and a release with minify off is fine. I knew early that R8 was the culprit.
So I added keep rules. When they didn't work, I widened them. Eventually I went this far:
-keep class ...BottomNavItem { *; }
-keep class ...BottomNavItem$* { *; }
-keepclassmembers class ...BottomNavItem { *; }
-keepclassmembers class ...BottomNavItem$* { *; }
-keepclassmembers class com.ootssu.plotta.** { *** route; }With all of that in v0.4.0 and minify re-enabled, it went quiet. In v0.5.x it regressed.
The keep rules weren't failing — the location was wrong
The pattern was this:
sealed class BottomNavItem(val route: String, ...)
data object Map : BottomNavItem("map", ...)
data object Leaderboard : BottomNavItem("leaderboard", ...)R8 inlines super-constructor arguments. A data object has a single instance with fixed state, so R8 decides "the constructor parameter is never read again after construction" and removes the route field itself. And it does this on the base class, not the child's synthetic class. That's why keeping the child classes did nothing.
Combined with fullMode optimization or a stale build cache, route ends up null. So it reproduced on some builds and not others, and the v0.4.0 I judged "fixed" was actually luck.
The answer wasn't a more precise keep rule. It was changing the pattern.
enum class BottomNavItem(val route: String, val labelRes: Int, val icon: ImageVector) {
MAP(...), LEADERBOARD(...), PROFILE(...);
companion object { val all = entries.toList() }
}R8 preserves enum fields automatically. No keep rule at all. Switched in v0.5.3 and it was over (commit 382b90cb).
"A collection of sealed-class data objects each holding a route String" is an extremely common Compose Navigation pattern — it's in the official examples. But it pairs badly with R8 fullMode. Use an enum from the start and this problem doesn't exist.
Apps in this story: Plotta (Android) · iOS.

Honestly
- I fought at the wrong layer across three versions. Widening keep rules is the "I don't know what R8 does, so keep everything" approach, and the wider it gets, the less you understand why it works or doesn't. Accepting v0.4.0's quiet as a fix was especially bad — a broad keep rule had merely masked the symptom while I believed I understood the cause.
- Mid-way, v0.3.1 worked around it by turning minify off entirely. A release shipped that way, giving up app size and performance.
- I still don't know exactly why it regressed. I suspect a stale build cache overlapping with fullMode optimization, but I couldn't pin the repro conditions. Switching to an enum made the problem itself vanish, so I didn't dig further. I dodged the root cause by replacing the pattern rather than fully diagnosing it.
- I didn't validate this conclusion on other data-object patterns. I couldn't tell whether sealed class + data object is broadly risky or whether it's specific to the Navigation route pattern.
Version recap: v0.3.0–v0.5.2 symptom / v0.3.1 minify-off workaround / v0.4.0 broad keep then re-enable (regressed) / v0.5.3 enum switch, resolved.
Sometimes the answer isn't "use the keep rule better," it's "don't use that pattern." If your proguard file has keep rules that keep getting wider, that may be a sign you still don't know what the optimizer is doing.