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

419 lines
15 KiB
Markdown

# 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 `CoroutineWorker`s | `-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
```proguard
# 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](../../../../RELEASE_OPS.md). 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:
```bash
./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:
```bash
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
```gradle
android {
buildTypes {
release {
minifyEnabled = true
shrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
minifyEnabled = false
}
}
}
```
### Multiple Proguard Files
```gradle
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
```bash
# Using retrace (part of Android SDK)
retrace.sh mapping.txt stacktrace.txt
```
### Enable Proguard Output
```gradle
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
```bash
./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:**
```proguard
-keep class com.example.YourClass {
public <methods>;
}
```
### Issue: Serialization Fails
**Cause:** Data class fields were renamed.
**Solution:**
```proguard
-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:**
```proguard
-keepclasseswithmembernames class * {
native <methods>;
}
```
## Optimization Tips
### 1. Keep Only What's Necessary
Don't use broad wildcards:
```proguard
# 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
```gradle
android {
buildTypes {
release {
// Generate R8 configuration
android.debug.obsoleteApi = true
}
}
}
```
### 4. Analyze APK Size
```bash
# 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
# proguard-play.pro
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }
```
### F-Droid Flavor (No Google Services)
```proguard
# proguard-fdroid.pro
# UnifiedPush
-keep class org.unifiedpush.** { *; }
```
**Configure in build.gradle:**
```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
- [Android R8 Documentation](https://developer.android.com/build/shrink-code)
- [Proguard Manual](https://www.guardsquare.com/manual/configuration)
- [Kotlinx Serialization Proguard](https://github.com/Kotlin/kotlinx.serialization#android)