fix(r8): two release-only crashes the minified build could not start without

Both reproduce on a Pixel 9 emulator and an SM-T220, on every launch, and
neither can happen on main, where -dontobfuscate turns renaming off.

1. Enums used as navigation-route arguments must keep their CLASS name.
   androidx.navigation resolves them with Class.forName on the serial name, so a
   renamed enum throws while the graph is being built:

     IllegalArgumentException: Cannot find class with name
     "...routes.DiscoverTab?"
       at NavTypeConverter_androidKt.parseNullableEnum
       at AppNavigationKt.BuildNavigation$lambda$4$0(AppNavigation.kt:1630)

   The blanket `-keepclassmembers enum *` does not cover it: it pins constant
   names and deliberately lets the class be renamed. Two enums are route
   arguments (DiscoverTab, BookmarkType) and keeping only the first just moves
   the crash to the second.

2. JacksonMapper built its JavaTypes through jacksonTypeRefOf(), which reads the
   type argument back off an anonymous TypeReference subclass. R8 full mode does
   not keep that, so <clinit> threw

     IllegalArgumentException: Internal error: TypeReference constructed without
     actual type information

   and a failed <clinit> is permanent -- every later use came back as
   NoClassDefFoundError, through EventHasher -> NostrSignerInternal.sign, so the
   build could not hash or sign a single event. Keep rules do NOT fix this:
   `-keep,allowobfuscation class * extends TypeReference` and a full
   `-keep ... { *; }` with Signature,InnerClasses,EnclosingMethod were both
   tried on device and both still failed. TypeFactory takes the Class objects
   directly, so there is nothing left to erase.

The same reflective pattern still backs JacksonMapper.fromJsonTo<T>,
JsonMapperNip55 (NIP-55) and the NIP-46 bunker path. Those need an external
signer to exercise and were not reachable in this run, so they are untested and
exposed the same way.

Both enums are added to the R8 reflection contract, which had no entry for
either and so passed 21/21 against a build that could not get past login. The
new lines were mutation-tested: renaming DiscoverTab in a copy of mapping.txt
makes the verifier exit 1 and name it.

Verified on both devices after the fix: no FATAL EXCEPTION, no TypeReference or
nav failures, profiles load from relays, 109 relay filters active, all five
tabs plus Bookmarks/Drafts/Scheduled posts open, and a theme change survives a
force-stop (enum -> DataStore round-trip).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-20 11:23:58 -04:00
co-authored by Claude Opus 5
parent 3ff075c4b7
commit 00f3fa3882
3 changed files with 71 additions and 10 deletions
+37 -3
View File
@@ -65,9 +65,25 @@
# Generic signatures, plus the inner/enclosing-class links that travel with them.
#
# Signature is the load-bearing one: jacksonTypeRefOf() resolves generic types
# through it at 18 call sites, and without it List<Event> erases to List and every
# element comes back a LinkedHashMap.
# Signature carries the generic type arguments R8 would otherwise erase.
#
# It is NOT enough to make `object : TypeReference<T>() {}` work under full mode
# (android.enableR8.fullMode=true). Measured on device: JacksonMapper's <clinit>
# built its JavaTypes through jacksonTypeRefOf() and threw
#
# IllegalArgumentException: Internal error: TypeReference constructed without
# actual type information
#
# which poisons the class -- every later use is NoClassDefFoundError, so nothing
# could be signed or sent. Keeping the anonymous subclasses did not help either
# (tried -keep,allowobfuscation and a full -keep ... { *; }). The fix was to stop
# asking Jackson to read the type back off the class: JacksonMapper now builds
# those JavaTypes with TypeFactory.constructType/constructCollectionType/
# constructParametricType, which take the Class objects directly.
#
# Anything else that still resolves a generic type reflectively is exposed the
# same way -- notably JacksonMapper.fromJsonTo<T>, JsonMapperNip55 (NIP-55) and
# the NIP-46 bunker path, which were not reachable in this test run.
#
# All three are ALSO in AGP's proguard-android-optimize.txt, which keeps
# AnnotationDefault, EnclosingMethod, InnerClasses, Signature and the three
@@ -198,3 +214,21 @@
# generated neighbours — cheap enough not to be worth proving unnecessary
# against a pre-stable (alpha) library.
-keep class com.vitorpamplona.amethyst.appfunctions.** { *; }
# -----------------------------------------------------------------------------
# Enums used as navigation-route ARGUMENTS
# -----------------------------------------------------------------------------
# androidx.navigation's type-safe routes resolve an enum argument by its
# fully-qualified class name (NavTypeConverter.parseEnum/parseNullableEnum call
# Class.forName on the serial name). R8 renames the class, so building the nav
# graph throws and the app cannot get past login:
#
# IllegalArgumentException: Cannot find class with name
# "...routes.DiscoverTab?". Ensure that the serialName for this argument is
# the default fully qualified name.
#
# The enum FIELDS are already pinned by the blanket `-keepclassmembers enum *`
# above; that rule deliberately lets the CLASS be renamed, which is exactly what
# breaks here. Every enum used as a route argument needs its name too.
-keep class com.vitorpamplona.amethyst.ui.navigation.routes.DiscoverTab { *; }
-keep class com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType { *; }
@@ -101,14 +101,28 @@ class JacksonMapper {
/**
* Shortcuts
*
* Built from Class objects, NOT from jacksonTypeRefOf(). A TypeReference
* reads its type argument back off the anonymous subclass's generic
* superclass, and R8 in full mode does not keep that -- not with
* `-keepattributes Signature`, and not with a `-keep` on the subclasses
* either (both were tried on device). It failed here, in <clinit>, with
*
* IllegalArgumentException: Internal error: TypeReference constructed
* without actual type information
*
* and a failed <clinit> is permanent: every later touch of this class
* throws NoClassDefFoundError, so the release build could not hash or sign
* a single event. TypeFactory takes the Class objects directly, so there is
* nothing for R8 to erase.
*/
val eventTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRefOf<Event>())
val tagArrayTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRefOf<TagArray>())
val rumorTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRefOf<Rumor>())
val eventTemplateTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRefOf<EventTemplate<Event>>())
val eventListTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRefOf<List<Event>>())
val messageTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRefOf<Message>())
val commandTypeInstance: JavaType = mapper.typeFactory.constructType(jacksonTypeRefOf<Command>())
val eventTypeInstance: JavaType = mapper.typeFactory.constructType(Event::class.java)
val tagArrayTypeInstance: JavaType = mapper.typeFactory.constructType(Array<Array<String>>::class.java)
val rumorTypeInstance: JavaType = mapper.typeFactory.constructType(Rumor::class.java)
val eventTemplateTypeInstance: JavaType = mapper.typeFactory.constructParametricType(EventTemplate::class.java, Event::class.java)
val eventListTypeInstance: JavaType = mapper.typeFactory.constructCollectionType(List::class.java, Event::class.java)
val messageTypeInstance: JavaType = mapper.typeFactory.constructType(Message::class.java)
val commandTypeInstance: JavaType = mapper.typeFactory.constructType(Command::class.java)
fun fromJson(json: String): Event = mapper.readValue(json, eventTypeInstance)
+13
View File
@@ -102,6 +102,19 @@ class com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
# every field name as a string literal — so there is no rule to verify.
# --- Enums used as navigation-route arguments: the CLASS name is the lookup ---
# androidx.navigation resolves an enum route argument by fully-qualified class
# name (NavTypeConverter calls Class.forName on the serial name). Renaming the
# class throws while the nav graph is being built, so the app cannot get past
# login at all -- release-only, and total.
#
# These need the CLASS pinned, which the `-keepclassmembers enum *` rule below
# deliberately does not do: it keeps constant names and lets the class be
# renamed. Add a line here whenever an enum becomes a route argument.
class com.vitorpamplona.amethyst.ui.navigation.routes.DiscoverTab
class com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
# --- Enum constants persisted as strings -------------------------------------
# The preference stores write `enum.name` into DataStore and read it back with
# valueOf(). A renamed constant resets that setting for every existing user on