Files
amethyst/.claude/skills/android-expert/references/proguard-rules.md
Claude 52ad5b9bbb fix: stop comparing a class name R8 renames, and scope the contract per flavor
Audit of what still reflects now that obfuscation is on. Two live defects, both
invisible in a debug build:

1. AmethystAppFunctions.requireInProcessSigner() compared
   `signer::class.qualifiedName` against the fully-qualified name of
   NostrSignerExternal, with a comment explaining that this avoided an import.
   R8 renames that class — `NostrSignerExternal -> nbc` in the release mapping —
   so the comparison never matched and the external-signer branch stopped
   running the moment obfuscation was enabled. A user on Amber invoking a write
   AppFunction from Gemini would fall through to the write attempt instead of
   the typed "not supported here" message. It is a plain `is` check now; the
   class is already imported directly by two other files in this source set.

2. The reflection contract listed AmethystCastOptionsProvider unscoped, but that
   class only exists in the play flavor, and the release workflow runs the
   verifier for playRelease AND fdroidRelease. Absent from a mapping reads as
   "R8 deleted it", so the next release would have failed on fdroid. Measured,
   not guessed: the old contract reports 2 of 21 checks failed against a real
   fdroidRelease mapping. Contract lines may now open with @play / @fdroid, the
   verifier derives the flavor from the mapping directory, and an unrecognised
   directory still checks everything.

Also from the audit:

  - Deleted 16 keep rules that matched nothing: libscrypt (com.lambdaworks),
    NetCipher (info.guardianproject), LazySodium and the whole JNA block.
    None of those artifacts appear in mapping.txt, usage.txt or seeds.txt —
    quartz replaced libsodium with LibSodiumInstance and Tor runs through arti
    now. Two of them were `-keep class * implements …` wildcards.

  - Added NwcErrorCode to the contract. Its constant names are the NIP-47 wire
    strings: the response parser calls NwcErrorCode.valueOf() on a string
    another wallet wrote, inside a try/catch that falls back to null. The
    blanket enum rule covers it today, which is exactly why it needs recording
    before that rule is retired.

  - Added AmethystAppFunctions (@play), so the one package keep that had no
    contract line is no longer unverified.

Everything else that reflects is either a library that ships its own consumer
rules (appfunctions, kotlinx-serialization companions, WorkManager, lifecycle
ViewModel constructors, Startup, Cast, AppSearch) or names a class R8 cannot
rename: quic's JdkCertificateValidator probes the *platform* trust manager for
the 3-arg checkServerTrusted, and every setClassName() target is a
manifest-declared component that AAPT2 already generates a keep for.

Verified: 21/21 on a fresh playRelease, 19/19 + 2 skipped on fdroidRelease,
both from full assembles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEoxktEZyTrAS33vVZBiwm
2026-09-19 15:50:02 +00:00

15 KiB

Proguard Rules for Amethyst

Proguard configuration for optimizing and obfuscating Android APK while preserving necessary code.

What is Proguard/R8?

R8 is Android's default code shrinker and obfuscator (replaced Proguard in AGP 3.4.0+). It:

  • Shrinks code by removing unused classes/methods
  • Obfuscates code by renaming classes/methods to short names
  • Optimizes code by inlining methods and removing dead code

Amethyst Proguard Configuration

Files:

  • amethyst/proguard-rules.pro — the app's rules. Read it before adding anything: it is commented rule by rule.
  • quartz/consumer-rules.pro — merged into the R8 configuration of every app that depends on Quartz, this one included (wired via optimization.consumerKeepRules in quartz/build.gradle.kts). A rule added here silently applies to somebody else's whole program.
  • The AGP default proguard-android-optimize.txt, which already contributes the Android-wide basics (Parcelable CREATOR fields, native <methods>, the enum values()/valueOf() pair, …). Don't restate its rules.
  • commons/, commonsUI/ and the other library modules deliberately have no rules files. They are not minified and they wire no consumer rules, so a file there would be dead configuration.

The policy: keep only what is reached BY NAME

Google Play measures how much of a shipped app's DEX R8 actually optimized and renamed, and warns — then restricts store visibility and publishing — below 25% in either category. Amethyst has been on the wrong side of that line: a -dontobfuscate plus -keepnames class ** { *; } at the top of both proguard-rules.pro and quartz/consumer-rules.pro, and outright -keep class com.vitorpamplona.** { *; } for the app's own code, produced 0% obfuscation and 13% optimization. (-keepnames is not the mild rule it looks like: it expands to -keep,allowshrinking, which permits shrinking but neither renaming nor optimization — applied to ** it disables R8 for the entire program, libraries included.)

So the standing rule is: a keep needs a named runtime mechanism that reads the name. In this app those are, exhaustively:

Mechanism Example Rule shape
JNI symbol Java_<class>_<method> ArtiNative, secp256k1 covered by the default native <methods> rule
Native code calling back by name ArtiLogCallback.onLogLine, looked up with GetMethodID in tools/arti-build/src/lib.rs -keep class …ArtiLogCallback { *; }
Generated code reflected over by a library the AppFunctions bridge (appfunctions.**, play flavor only) -keep class <pkg>.** { *; }
Enum constant persisted as a string UISharedPreferences writes enum.name, reads Type.valueOf(s) -keepclassmembers enum * { <fields>; … }
Class name in a manifest <meta-data android:value> AmethystCastOptionsProvider explicit -keep — AGP generates keeps from component android:name, not from meta-data
Class name in WorkManager's database the three CoroutineWorkers -keep class * extends androidx.work.ListenableWorker { <init>(...); }

What does not need a keep, and where the temptation usually comes from:

  • Quartz events and tags. Event, Filter, Message, Command, Rumor, EventTemplate, TagArray, the NIP-46 Bunker messages and the NIP-55 intent results all go through hand-written StdSerializer/StdDeserializer pairs registered on JacksonMapper / JsonMapperNip55. Those read and write property names as string literals, and EventFactory dispatches on kind with a when, not by reflection. Renaming their fields changes nothing on the wire.
  • @Serializable (kotlinx) classes, including the type-safe navigation routes. The compiler plugin generates a descriptor holding the serial name and every property name as compile-time string literals, so obfuscation cannot reach them. kotlinx-serialization ships its own consumer rules for the $$serializer/Companion plumbing.
  • Compose, Coil, OkHttp, Media3, Firebase, kotlin-reflect. Every one of them ships consumer rules inside its own artifact. Check build/outputs/mapping/<variant>/configuration.txt — the fully merged configuration — before writing a rule for a third-party library.
  • Manifest-declared components (activities, services, receivers, providers) and classes named in layout/res/xml. AGP generates those keeps itself, which is why Intent().setClassName(ctx, "…NappletBrowserService") is safe.

Attributes

# What makes a crash report retraceable.
-keepattributes SourceFile,LineNumberTable

# jackson-module-kotlin reads @kotlin.Metadata; R8 requires InnerClasses and
# EnclosingMethod alongside Signature.
-keepattributes *Annotation*,Signature,Exceptions,InnerClasses,EnclosingMethod

LocalVariableTable, LocalVariableTypeTable, MethodParameters and -keepparameternames are debug metadata that nothing in the app reads — jackson-module-kotlin takes parameter names from @kotlin.Metadata, not from MethodParameters. They were removed; don't add them back.

-renamesourcefileattribute is deliberately not set, and the reason is not the usual one.

Once R8 is minifying it rewrites every class's SourceFile to the marker r8-map-id-<hash> on its own — there is no rule that restores the original per-class .kt name. Verified by building it both ways: with -renamesourcefileattribute SourceFile, all 24,440 classes report the literal "SourceFile"; without it, they report the marker. So the rule cannot buy readability, it can only destroy the marker — and that marker is the pg_map_id header of the mapping that produced the build, which is what lets a pasted stack trace name the exact mapping file it needs.

Two related facts worth knowing before someone tries to "fix" stack traces with keep rules:

  • Raw line numbers are no longer source line numbers. R8 renumbers them so that one obfuscated line can encode a whole inlined frame stack. This is a cost of optimization, not of renaming — it is new only because the old blanket -keepnames had optimization switched off across the program, which is the thing Play was flagging.
  • Retrace therefore returns more than the old raw traces did: it expands the frames R8 inlined instead of collapsing them into one misleading line. A one-frame crash can retrace to three.

The workflow is scripts/retrace.sh <mapping> [trace], documented in RELEASE_OPS.md § 7. Every GitHub Release carries amethyst-{googleplay,fdroid}-mapping-<version>.txt.gz; Play Console needs nothing because AGP embeds the mapping in the .aab under BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map.

Verifying a change to these rules

R8 cannot see reflection, so a wrong keep rule fails only in a release build, at runtime. Before changing them:

./gradlew :amethyst:assemblePlayRelease -PdisableAbiSplits=true -PdisableUniversalApk=true

then read amethyst/build/outputs/mapping/playRelease/:

  • configuration.txt — every rule R8 actually saw, including each AAR's consumer rules. This is the file that answers "does library X already keep itself?"
  • mapping.txt — what got renamed. Lines whose left and right sides are equal are classes a keep rule pinned; scan them for anything you did not intend.
  • seeds.txt / usage.txt — what the keeps matched, and what was removed.

The contract check

R8 cannot see reflection, so a keep rule that quietly stops matching — a class moved to another package, a rule deleted in a merge, a DTO renamed — gives you a green build and an APK that breaks on a user's device. tools/r8-verify/reflection-contract.txt lists every name resolved from outside the DEX, and the verifier asserts each against what R8 actually emitted:

python3 tools/r8-verify/verify_reflection_contract.py \
    amethyst/build/outputs/mapping/playRelease/

Under a second, no device. The release workflow runs it for both flavors before collecting assets, so a break fails the release instead of shipping.

Adding reflection means adding two things: the keep rule, and a line in the contract. A rule with no contract line is unverified and will rot.

Scope a line to one flavor with a leading @play / @fdroid when the class is only compiled into that variant — play-only code is absent from the F-Droid APK, and "absent" is indistinguishable from "R8 deleted it", so an unscoped line for it fails that release.

Reflection that names a class R8 cannot rename needs no rule and no line: the platform trust manager quic probes for a 3-arg checkServerTrusted ships in Android, not in our DEX. The opposite case is the one to watch — a name of ours used as a value. AmethystAppFunctions compared signer::class.qualifiedName against "…NostrSignerExternal"; R8 renames that class, so the branch silently stopped running the day obfuscation was turned on. Prefer is over a name comparison; there is no keep rule that makes the latter safe to write.

It reads both mapping.txt and usage.txt, because neither is enough alone — mapping.txt records only what changed (an intact -keep ... { *; } class has an empty body, and an unrenamed member has no line at all, so absence there means "preserved"), while a member R8 deleted appears only in usage.txt. It also cross-checks that the two files come from the same R8 run: mapping.txt is written during packaging, not at minify time, so a minify-only rebuild leaves a stale one behind next to a fresh usage.txt.

What it cannot cover is reflection nobody wrote down. For that: a staged Play rollout (the crash reporter retraces itself, so breaks are legible within hours), and exercising NIP-47 wallet connect, NIP-46 bunker login, Tor, scheduled posts and a settings round-trip (change theme/font, kill, relaunch) on a minified build — those are the paths the keeps above exist for.

Build Configuration

Enable R8 in build.gradle

android {
    buildTypes {
        release {
            minifyEnabled = true
            shrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }

        debug {
            minifyEnabled = false
        }
    }
}

Multiple Proguard Files

android {
    buildTypes {
        release {
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
                "proguard-quartz.pro",  // Library-specific rules
                "proguard-compose.pro"  // Compose-specific rules
            )
        }
    }
}

Debugging Proguard Issues

Generate Mapping File

R8 generates mapping.txt in app/build/outputs/mapping/release/:

# Original class name -> Obfuscated name
com.vitorpamplona.amethyst.ui.MainActivity -> a.b.c:
    void onCreate(Bundle) -> a

Deobfuscate Stack Traces

# Using retrace (part of Android SDK)
retrace.sh mapping.txt stacktrace.txt

Enable Proguard Output

android {
    buildTypes {
        release {
            proguardFiles(...)

            // Generate reports
            postprocessing {
                proguardFiles = [...]
                obfuscate = true
                optimizeCode = true
                removeUnusedCode = true
            }
        }
    }
}

Output files:

  • build/outputs/mapping/release/configuration.txt - All Proguard rules applied
  • build/outputs/mapping/release/mapping.txt - Obfuscation mappings
  • build/outputs/mapping/release/seeds.txt - Classes kept by -keep rules
  • build/outputs/mapping/release/usage.txt - Code removed by R8

Test Release Build

./gradlew assembleRelease

# Install and test
adb install app/build/outputs/apk/release/app-release.apk

Common Issues

Issue: NoSuchMethodException at Runtime

Cause: Proguard removed or renamed a method used via reflection.

Solution:

-keep class com.example.YourClass {
    public <methods>;
}

Issue: Serialization Fails

Cause: Data class fields were renamed.

Solution:

-keep @kotlinx.serialization.Serializable class * { *; }
-keepclassmembers class * {
    @kotlinx.serialization.SerialName <fields>;
}

Issue: Compose Navigation Crashes

Not the route classes being obfuscated — that cannot happen. A type-safe route's pattern comes from its kotlinx-serialization descriptor, and the compiler plugin bakes the serial name and every property name in as string literals, so renaming the class leaves the route string untouched. Adding -keep @kotlinx.serialization.Serializable class …routes.** { *; } pins a large tree for no reason and hides the real cause.

Look instead at whether navigation-common's own consumer rules made it into configuration.txt, and at the stack trace retraced through mapping.txt.

Issue: Native Library Crashes

Cause: Native method signatures were changed.

Solution:

-keepclasseswithmembernames class * {
    native <methods>;
}

Optimization Tips

1. Keep Only What's Necessary

Don't use broad wildcards:

# Bad - keeps everything
-keep class com.vitorpamplona.** { *; }

# Good - keeps only specific packages
-keep class com.vitorpamplona.quartz.events.** { *; }

2. Test Thoroughly

  • Test all app features after enabling Proguard
  • Test deep links and navigation
  • Test serialization/deserialization
  • Test external library integrations

3. Use AGP's Proguard Analysis

android {
    buildTypes {
        release {
            // Generate R8 configuration
            android.debug.obsoleteApi = true
        }
    }
}

4. Analyze APK Size

# Build release APK
./gradlew assembleRelease

# Analyze APK with Android Studio
# Build > Analyze APK > Select app-release.apk

See scripts/analyze-apk-size.sh for automated analysis.

Product Flavor Specific Rules

Play Flavor (Firebase)

# proguard-play.pro
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }

F-Droid Flavor (No Google Services)

# proguard-fdroid.pro
# UnifiedPush
-keep class org.unifiedpush.** { *; }

Configure in build.gradle:

android {
    flavorDimensions = ["channel"]
    productFlavors {
        create("play") {
            dimension = "channel"
            proguardFiles("proguard-play.pro")
        }
        create("fdroid") {
            dimension = "channel"
            proguardFiles("proguard-fdroid.pro")
        }
    }
}

File Locations

  • amethyst/proguard-rules.pro - Main Proguard rules
  • amethyst/build/outputs/mapping/release/ - Proguard output files
  • amethyst/build.gradle - Proguard configuration

Resources