Merge remote-tracking branch 'origin/main' into claude/push-notification-black-screens-heg0ze

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt
This commit is contained in:
Claude
2026-09-21 22:13:09 +00:00
846 changed files with 31614 additions and 5795 deletions
+13 -1
View File
@@ -224,9 +224,21 @@ version. `quartz/` is protocol-only — no composables.
# Build Quartz for all targets
./gradlew :quartz:build
# Run tests
# Run the JVM tests of every module, KMP ones included. KMP modules register no
# `test` task of their own; the root build aliases it onto their jvmTest.
./gradlew test
# A single module. For a KMP module name jvmTest directly:
./gradlew :quartz:jvmTest --tests "com.vitorpamplona.quartz.nip52Calendar.*"
# NOT covered by `test`: each KMP module's OTHER targets, notably
# :quartz:testAndroidHostTest (~3.9k tests, its own androidHostTest source set).
# Nothing runs it today - not `test`, not pre-push, not CI - and it has 4 known
# failures on main (NostrServerTest x3, LiveNegentropyIndexStoreTest x1), which
# is why the alias maps to jvmTest rather than allTests. Run it explicitly when
# touching relay-server or store code:
./gradlew :quartz:testAndroidHostTest
# Format code
./gradlew spotlessApply
```
+4
View File
@@ -1,4 +1,8 @@
{
"env": {
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8"
},
"hooks": {
"PreToolUse": [
{
@@ -11,230 +11,178 @@ Proguard configuration for optimizing and obfuscating Android APK while preservi
## Amethyst Proguard Configuration
**File:** `amethyst/proguard-rules.pro`
**Files:**
### Keep Kotlin Metadata
- `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
# Kotlin metadata is required for reflection
-keep class kotlin.Metadata { *; }
-keep class kotlin.** { *; }
-dontwarn kotlin.**
# Kotlin serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class com.vitorpamplona.**$$serializer { *; }
-keepclassmembers class com.vitorpamplona.** {
*** Companion;
}
-keepclasseswithmembers class com.vitorpamplona.** {
kotlinx.serialization.KSerializer serializer(...);
}
```
### Keep Nostr Event Classes
```proguard
# Nostr events are serialized/deserialized
-keep class com.vitorpamplona.quartz.events.** { *; }
-keep class com.vitorpamplona.quartz.encoders.** { *; }
# Keep event builders
-keep class com.vitorpamplona.quartz.builders.** { *; }
# Keep tag classes
-keep class com.vitorpamplona.quartz.nip01Core.tags.** { *; }
```
### Keep Data Classes
```proguard
# Data classes used in ViewModels and serialization
-keep @kotlinx.serialization.Serializable class * { *; }
# Keep all data classes
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
```
### Keep Compose Classes
```proguard
# Jetpack Compose
-keep class androidx.compose.** { *; }
-dontwarn androidx.compose.**
# Compose runtime
-keep class androidx.compose.runtime.** { *; }
# Compose UI
-keep class androidx.compose.ui.** { *; }
# Material3
-keep class androidx.compose.material3.** { *; }
# Navigation Compose - Keep serializable routes
-keep class * implements java.io.Serializable { *; }
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
!static !transient <fields>;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
```
### Keep OkHttp/Retrofit
```proguard
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
# OkHttp WebSockets (for Nostr relays)
-keep class okhttp3.internal.ws.** { *; }
# Retrofit (if used)
-keepattributes Signature
-keepattributes Exceptions
-keep class retrofit2.** { *; }
```
### Keep Jackson (JSON)
```proguard
# Jackson JSON library
-keep class com.fasterxml.jackson.** { *; }
-keep class org.codehaus.** { *; }
-keepclassmembers class * {
@com.fasterxml.jackson.annotation.* <methods>;
}
# Jackson polymorphic types
-keepattributes RuntimeVisibleAnnotations
-keep @com.fasterxml.jackson.annotation.JsonTypeInfo class *
```
### Keep Secp256k1 (Crypto)
```proguard
# Secp256k1 native library
-keep class fr.acinq.secp256k1.** { *; }
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
```
### Keep Tor
```proguard
# Tor library
-keep class com.msopentech.thali.toronionproxy.** { *; }
-dontwarn com.msopentech.thali.toronionproxy.**
```
### Keep ExoPlayer (Media)
```proguard
# ExoPlayer (Media3)
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
-keep class com.google.android.exoplayer2.** { *; }
-dontwarn com.google.android.exoplayer2.**
```
### Keep Coil (Image Loading)
```proguard
# Coil image loading
-keep class coil.** { *; }
-keep class coil3.** { *; }
-dontwarn coil.**
-dontwarn coil3.**
```
### Keep ViewModels
```proguard
# ViewModel classes
-keep class * extends androidx.lifecycle.ViewModel {
<init>();
}
# ViewModel factories
-keep class * extends androidx.lifecycle.ViewModelProvider$Factory {
<init>(...);
}
# Keep ViewModel constructors for reflection
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
<init>(...);
}
```
### Keep Parcelable
```proguard
# Parcelable
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
-keepclassmembers class * implements android.os.Parcelable {
public <fields>;
private <fields>;
}
```
### Keep Enums
```proguard
# Enums
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
```
### Remove Logging (Production)
```proguard
# Remove debug logging in release builds
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
# Keep error/warning logs
-assumenosideeffects class android.util.Log {
public static *** e(...) return false;
public static *** w(...) return false;
}
```
### Keep Crashlytics/Firebase
```proguard
# Firebase Crashlytics
# What makes a crash report retraceable.
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception
# Firebase
-keep class com.google.firebase.** { *; }
-dontwarn com.google.firebase.**
# 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
@@ -356,13 +304,15 @@ adb install app/build/outputs/apk/release/app-release.apk
### Issue: Compose Navigation Crashes
**Cause:** @Serializable route classes were obfuscated.
**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.
**Solution:**
```proguard
# Keep all route classes
-keep @kotlinx.serialization.Serializable class com.vitorpamplona.amethyst.ui.navigation.routes.** { *; }
```
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
+3 -3
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.15.2` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.16.0` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.15.2"
quartz = "1.16.0"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
implementation("com.vitorpamplona.quartz:quartz:1.16.0")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.15.2
com.vitorpamplona.quartz:quartz:1.16.0
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.15.2"
quartz = "1.16.0"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
implementation("com.vitorpamplona.quartz:quartz:1.16.0")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
implementation("com.vitorpamplona.quartz:quartz:1.16.0")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
@@ -2,9 +2,9 @@
Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()`
expression. **Update this file in the same PR as any change to the searchable set or to an
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-25.
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-09-17.
Counts: 130 concrete classes covering 133 kind values (`GitStatusEvent` spans 4 kinds;
Counts: 133 concrete classes covering 136 kind values (`GitStatusEvent` spans 4 kinds;
kind 30063 has a collision — see the footnote). File paths are under
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
@@ -55,6 +55,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` |
| 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` |
| 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` |
| 7516 | GeocacheFoundLogEvent | nipCCGeocaching/foundLog | `content` |
| 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` |
| 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL |
| 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL |
@@ -129,6 +130,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
| 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
| 36787 | MusicTrackEvent | experimental/music/track | `listOfNotNull(title(), artist(), album(), content)` NL |
| 37516 | GeocacheListingEvent | nipCCGeocaching/listing | `listOfNotNull(cacheName(), content)` NL (the `hint` is deliberately not indexed — matching a hint is spoiling it) |
| 37517 | GeocacheCurationListEvent | nipCCGeocaching/curation | `listOfNotNull(title(), description(), content)` NL |
| 38000 | MintRecommendationEvent | nip87Ecash/recommendation | `content` |
| 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL |
| 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) |
+10
View File
@@ -16,11 +16,21 @@ echo "Running test... "
# variants of :amethyst (play/fdroid × debug/release/benchmark) plus full
# native-libs merging per variant — ~6× the work of one variant. CI runs the
# multi-flavor matrix on push to main; pre-push only needs one happy path.
#
# Every KMP module's tests live under <module>:jvmTest - the Kotlin Multiplatform
# plugin registers no plain `test` task - so a module missing from this list is a
# module nobody runs before pushing. Add new KMP modules here when they gain tests.
#
# `./gradlew test` now reaches the KMP modules too (see the `test` alias in the root
# build), but this hook stays explicit: a bare `test` would also pull in :desktopApp:test,
# which needs a display server and is deliberately skipped on CLAUDE_CODE_REMOTE below.
TASKS=(
:quartz:jvmTest
:commons:jvmTest
:commonsUI:jvmTest
:nestsClient:jvmTest
:quic:jvmTest
:marmotQuic:jvmTest
:amethyst:testPlayDebugUnitTest
:cli:test
)
+4 -3
View File
@@ -102,15 +102,16 @@ jobs:
- name: Test + Build Desktop (gradle)
run: |
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :commonsUI:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :commonsUI:jvmTest :quic:jvmTest :nestsClient:jvmTest :marmotQuic:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
if [ "${{ runner.os }}" = "Linux" ]; then
xvfb-run --auto-servernum $CMD
else
$CMD
fi
# This job runs five test suites (:quartz, :commons, :nestsClient, :cli,
# :desktopApp) but, unlike test-geode / test-quartz-ios /
# This job runs the JVM test suites (:quartz, :commons, :commonsUI, :quic,
# :nestsClient, :marmotQuic, :cli, :desktopApp) but, unlike test-geode /
# test-quartz-ios /
# test-and-build-android, published nothing when one of them failed. The
# console line names the failing test and the exception class and stops
# there, so the message is lost with the runner. That is how the
+45
View File
@@ -1018,6 +1018,23 @@ jobs:
env:
BUILD_TOOLS_VERSION: "36.0.0"
# R8 cannot see reflection, so a keep rule that silently stops matching — a
# class moved to another package, a rule deleted, a DTO renamed — produces
# a green build and an APK that fails on a user's device. This asserts the
# reflective surface (JNI symbols, Jackson DTO field names, enum constants
# persisted in DataStore, WorkManager worker class names, the Cast
# OptionsProvider named in a manifest meta-data value) actually survived,
# against what R8 emitted for BOTH flavors. Runs before the assets are
# collected so a break fails the release rather than shipping.
- name: Verify R8 reflection contract
run: |
set -euo pipefail
for variant in playRelease fdroidRelease; do
echo "== $variant"
python3 tools/r8-verify/verify_reflection_contract.py \
"amethyst/build/outputs/mapping/${variant}/"
done
- name: Collect Android assets (rename to canonical scheme)
run: |
set -euo pipefail
@@ -1041,6 +1058,34 @@ jobs:
"dist/amethyst-googleplay-${TAG}.aab"
cp "amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab" \
"dist/amethyst-fdroid-${TAG}.aab"
# R8 mapping files — the ONLY way a crash report from this build is
# ever readable again. The release build is minified, so every class
# in every artifact above reports `r8-map-id-<hash>` as its source
# file and a renamed class/method; `scripts/retrace.sh <mapping>`
# turns that back into real names, files and lines (and expands the
# frames R8 inlined).
#
# Play Console deobfuscates by itself because AGP embeds the mapping
# in the .aab it was given. Nothing else does: a trace from an
# F-Droid, Zapstore, Accrescent or GitHub-APK user is unreadable
# without the matching file, and the mapping only exists on this
# runner. If it is not published here it is gone when the job ends.
#
# Gzipped because the raw text mapping is ~500 MB (~29 MB
# compressed). retrace.sh reads the .gz directly.
for flavor in play fdroid; do
case "$flavor" in
play) name=googleplay ;;
fdroid) name=fdroid ;;
esac
src="amethyst/build/outputs/mapping/${flavor}Release/mapping.txt"
if [ ! -f "$src" ]; then
echo "::error::$src is missing — the release would ship with no way to read its crash reports."
exit 1
fi
gzip -c "$src" > "dist/amethyst-${name}-mapping-${TAG}.txt.gz"
done
ls -la dist
# Accrescent does not accept AABs or monolithic APKs — it requires a signed
+45 -13
View File
@@ -89,8 +89,8 @@ cd amethyst
## Generated & vendored artifacts
Two build inputs are **generated by tools but committed to the repo**, so a
normal build or release does **not** run either — Gradle just consumes the
Three build inputs are **generated by tools but committed to the repo**, so a
normal build or release does **not** run any of them — Gradle just consumes the
checked-in output. You only regenerate them under the specific conditions below,
and each has its own guide:
@@ -98,16 +98,44 @@ and each has its own guide:
|---|---|---|---|
| **Material Symbols subset font** | `commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` |
| **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) |
| **zxing-cpp (QR decoder) native libs** | `amethyst/src/main/jniLibs/*/libzxingcpp_android.so` | You bump `ZXING_CPP_VERSION`, change the JNI wrapper, or want to reproduce the binaries | [`tools/zxing-cpp-build/README.md`](tools/zxing-cpp-build/README.md) |
> **Material Symbols is mandatory after icon changes.** The bundled font is a
> ~210-glyph subset; a new codepoint that isn't in it renders as tofu (□) at
> runtime. Regenerate and commit the `.ttf` alongside the `MaterialSymbols.kt`
> change. Reusing an existing codepoint needs no regeneration.
Both tools have their own prerequisites (`fonttools`/`brotli` for the font; a
Rust toolchain + the exact Android NDK revision pinned in
`tools/arti-build/ANDROID_NDK_VERSION` for Arti) documented in their READMEs —
they are **not** required to build Amethyst from the committed sources.
Each tool has its own prerequisites (`fonttools`/`brotli` for the font; a Rust
toolchain for Arti; `cmake` + `ninja` for zxing-cpp; and, for both native
builds, the exact Android NDK revision pinned in
`tools/arti-build/ANDROID_NDK_VERSION`) documented in their READMEs — none of
them are required to build Amethyst from the committed sources.
That NDK pin is a single file for the whole repo, not a copy per tool:
`build-arti.sh`, `build-zxingcpp.sh` and `:amethyst`'s `ndkVersion` all read it,
so bumping it moves every native build and the packaging toolchain together and
they cannot drift apart. (It lives under `tools/arti-build/` for history; it is
not Arti's alone.)
The NDK half of that pin reaches the ordinary Android build. AGP strips every
native library it packages with the NDK's `llvm-strip`, so `:amethyst` sets
`ndkVersion` from `ANDROID_NDK_VERSION` — one revision for the libraries we
build and the ones we merge from dependencies. Both of our own libraries are
then excluded from that strip step (`packaging.jniLibs.keepDebugSymbols`):
they are already stripped by the pinned toolchain, so skipping the pass costs
no size and lets the `.so` inside an APK be compared byte-for-byte against the
committed, independently reproducible one. The Rust toolchain stays irrelevant
either way; Studio/AGP fetches the pinned NDK on demand, or pre-install it with
`sdkmanager "ndk;$(cat tools/arti-build/ANDROID_NDK_VERSION)"`.
> **Every ABI split needs its own copy of each library.** The APK is split four
> ways (`arm64-v8a`, `x86_64`, `armeabi-v7a`, `x86`). A split missing
> `libarti_android.so` installs and runs with Tor silently unavailable; one
> missing `libzxingcpp_android.so` installs with the QR scanner broken. The
> `verifyNativeAbis` Gradle task fails the build if a library's ABI list drifts
> from the split list, and names the `build-arti.sh --target=…` /
> `build-zxingcpp.sh --abi …` to run — and rejects a file that is not an ELF
> built for that architecture, which a missing-file check would pass.
---
@@ -334,7 +362,7 @@ Quartz library in one pipeline.
3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min).
4. **Verify** — the GH Release should hold **47 assets**:
4. **Verify** — the GH Release should hold **49 assets**:
- **14 desktop**, one per matrix leg × format:
- macOS arm64: `dmg` (1)
- Windows x64: `msi` + portable `zip` (2)
@@ -349,8 +377,12 @@ Quartz library in one pipeline.
ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why
the x64 leg gets an MSI). Revisit if that image gains WiX, or if
jpackage learns the WiX 4+ `wix build` CLI.
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
F-Droid `.apks` set built for Accrescent.
- **15 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
F-Droid `.apks` set built for Accrescent + **2 R8 mapping files**
(`amethyst-{googleplay,fdroid}-mapping-<version>.txt.gz`). The mappings
are not optional extras: the release build is minified, so without them
no crash report from an APK/`.apks` user can be read. See
[`RELEASE_OPS.md` § Crash reports](RELEASE_OPS.md#7-crash-reports--retrace).
- **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64),
`deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the
one arch-independent no-JRE `amy-<ver>-jvm.tar.gz` for Homebrew-core.
@@ -534,7 +566,7 @@ reads an optional per-release changelog from
## Bootstrap runbook (one-time)
> **Status as of v1.15.2:** both Homebrew packages are now live upstream — the
> **Status as of v1.16.0:** both Homebrew packages are now live upstream — the
> `amethyst-nostr` cask (`Homebrew/homebrew-cask`, at 1.14.0) and the `amy`
> formula (`Homebrew/homebrew-core`) both answer 200 on `formulae.brew.sh`, so
> `bump-homebrew.yml` finally has something to bump. **Winget is still not
@@ -588,7 +620,7 @@ The token then lives only in that maintainer's shell:
```bash
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.15.2
scripts/bump-homebrew-cask.sh v1.16.0
```
Create one at
@@ -604,7 +636,7 @@ Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives
runs fine from macOS or Linux:
```bash
scripts/bump-winget.sh v1.15.2
scripts/bump-winget.sh v1.16.0
```
CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the
@@ -690,7 +722,7 @@ Caveats that the maintainer must weigh before submitting:
- **Bundle size.** The bundle used to be ~70 MB because `:commons` leaked
Compose/Skiko jars onto the CLI classpath. Compose UI now lives in
`:commonsUI`, which `:cli` does not depend on: the JVM tarball is ~55 MB
and the jlink image tarball ~80 MB (1.15.2, Linux x64). The release
and the jlink image tarball ~80 MB (1.16.0, Linux x64). The release
workflow caps every amy asset at 120 MB.
After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot
+20 -14
View File
@@ -3,7 +3,7 @@
**App:** Amethyst (Android Nostr client)<br>
**Publisher:** Vitor Pamplona<br>
**Contact:** amethyst@vitorpamplona.com<br>
**Last updated:** 2026-09-12
**Last updated:** 2026-09-15
Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device.
@@ -32,33 +32,39 @@ Configuration, cached events, keys, drafts, and other operational data live in t
### Health and fitness data (Health Connect)
Amethyst's **Workouts** section lets you publish a summary of a finished workout to the Nostr relays you choose (a NIP-101e kind 1301 event), so the people who follow you can see it. To save you typing the numbers in by hand, Amethyst can read the workout your watch or fitness app already saved to **Android Health Connect** and pre-fill the post.
Amethyst's **My Fitness** screen (drawer → You → My Fitness) summarises your own training for you: how much you did this week against last week, how your time splits across activities, your best efforts, how many days you trained, and your current streak. It builds that picture from the workouts your watch or fitness app has already saved to **Android Health Connect**.
The feature is optional and off until you grant the permissions. Amethyst asks for them only when you open the New Workout composer — never on first launch.
This is what the health permissions are for. The summary is computed on your phone and shown to you; nothing is sent anywhere to produce it, and you never have to post anything to use it.
My Fitness also counts the workouts you have logged in Amethyst itself, so it works whether or not you connect Health Connect. Connecting adds the sessions your watch recorded and the details a hand-typed workout does not carry — heart rate, steps and climb.
Separately, you may choose to publish one workout as a Nostr post (a NIP-101e kind 1301 event) so the people who follow you can see it. That takes a deliberate tap on "Share this workout", shows you the pre-filled post, and waits for you to confirm. It is never automatic.
The feature is optional and off until you grant the permissions. Amethyst asks for them only when you open My Fitness or the New Workout composer — never on first launch.
**What Amethyst reads, and what each type is for:**
| Health Connect data type | Permission | What it is used for |
| --- | --- | --- |
| ExerciseSession | `READ_EXERCISE` | The workout itself: activity type, start time and duration — the title, date and duration of the post. |
| Distance | `READ_DISTANCE` | The distance of the run, ride, walk or swim. |
| ActiveCaloriesBurned | `READ_ACTIVE_CALORIES_BURNED` | The energy the workout burned. |
| TotalCaloriesBurned | `READ_TOTAL_CALORIES_BURNED` | Fallback energy figure for sources that only record total energy. |
| HeartRate | `READ_HEART_RATE` | Average and maximum heart rate over the workout — how hard the effort was. |
| Steps | `READ_STEPS` | The step count of a run, walk or hike. |
| ElevationGained | `READ_ELEVATION_GAINED` | How much you climbed. |
| ExerciseSession | `READ_EXERCISE` | The workout itself: activity type, start and end. Drives your workout count, training time, per-activity breakdown, active days and streak. |
| Distance | `READ_DISTANCE` | Weekly distance, the change against last week, your weekly average, distance per activity, and your longest distance. |
| ActiveCaloriesBurned | `READ_ACTIVE_CALORIES_BURNED` | Weekly energy burned and its week-over-week change. |
| TotalCaloriesBurned | `READ_TOTAL_CALORIES_BURNED` | Fallback for the same figure, for watches and apps that only record total energy. |
| HeartRate | `READ_HEART_RATE` | Average and maximum heart rate per workout, your duration-weighted average for the period, and your highest heart rate. |
| Steps | `READ_STEPS` | Your weekly step average and your highest step count. |
| ElevationGained | `READ_ELEVATION_GAINED` | Your weekly climb average and your biggest climb. |
Health Connect groups a few data types under one permission: `READ_EXERCISE` also covers CyclingPedalingCadence and `READ_STEPS` also covers StepsCadence. Amethyst does not read, store, or publish cadence — those types come attached to the permissions above and are never requested separately.
**Limits on this access:**
- **Read-only.** Amethyst never writes to Health Connect.
- **Foreground only.** Reads happen only while the New Workout composer is on screen. Amethyst does not request `READ_HEALTH_DATA_IN_BACKGROUND` and has no background health worker.
- **Last 7 days only.** Only sessions that finished in the previous 7 days are offered. Amethyst does not request `READ_HEALTH_DATA_HISTORY`.
- **Foreground only.** Reads happen only while the My Fitness screen or the New Workout composer is on screen. Amethyst does not request `READ_HEALTH_DATA_IN_BACKGROUND` and has no background health worker.
- **Last four weeks only.** Amethyst reads a rolling 28-day window and cannot see anything older. It does not request `READ_HEALTH_DATA_HISTORY`.
- **No location.** Amethyst does not request `READ_EXERCISE_ROUTE`, so it never receives the GPS track of a workout.
- **Nothing is uploaded automatically.** Health data stays on your device until you pick a suggestion, review the pre-filled post, and publish it yourself. The developer runs no server; a published post goes to the Nostr relays you configured, and those numbers then become public like any other post you make.
- **Nothing is uploaded automatically.** Health data stays on your device. The My Fitness summary is computed locally and never transmitted. A workout only leaves your phone if you tap "Share this workout", review the pre-filled post, and publish it yourself — one workout at a time. The developer runs no server; a published post goes to the Nostr relays you configured, and those numbers then become public like any other post you make.
- **No other use.** Health data is never used for advertising, analytics, profiling, or sale, and is never shared with third parties. It is not used to determine your eligibility for insurance, credit, or employment, and is not transferred to any such party.
- **Revocable.** Turn the feature off under Settings → Compose → "Suggest workouts to share", or revoke the permissions in Health Connect at any time. Amethyst keeps the workout suggestions it has already shown only in memory; revoking access stops all reads immediately.
- **Revocable.** Revoke the permissions in Health Connect at any time — My Fitness immediately drops back to its prompt — or turn the composer suggestions off under Settings → Compose Settings → "Suggest workouts to share". Amethyst keeps the summary and the suggestions only in memory; revoking access stops all reads immediately.
### What relays can see
+5 -5
View File
@@ -328,16 +328,16 @@ repositories {
Add the following line to your `commonMain` dependencies:
```gradle
implementation('com.vitorpamplona.quartz:quartz:1.15.2')
implementation('com.vitorpamplona.quartz:quartz:1.16.0')
```
Variations to each platform are also available:
```gradle
implementation('com.vitorpamplona.quartz:quartz-android:1.15.2')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.15.2')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.15.2')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.15.2')
implementation('com.vitorpamplona.quartz:quartz-android:1.16.0')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.16.0')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.16.0')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.16.0')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
+117 -3
View File
@@ -130,6 +130,11 @@ Nothing to do beyond pushing the tag. Verify the asset count (BUILDING.md
§ Verify). macOS is **arm64-only** — there is no Intel DMG, so a single
`amethyst-desktop-<version>-macos-arm64.dmg` is the expected, correct result.
Two of those assets are the R8 mapping files
(`amethyst-{googleplay,fdroid}-mapping-<version>.txt.gz`). Do not prune them
from old releases — they are the only way to read a crash report from a build
that old (§ 7).
### Google Play — manual upload
1. Download `amethyst-googleplay-<version>.aab` from the GH Release.
2. Play Console → app `com.vitorpamplona.amethyst` → **Production** (or the
@@ -240,9 +245,9 @@ readable by anyone with push access here), so a maintainer runs the last step:
```bash
# after merging the sync PRs
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.15.2
scripts/bump-homebrew-cask.sh v1.16.0
scripts/bump-winget.sh v1.15.2 # no token — uses your `gh` auth
scripts/bump-winget.sh v1.16.0 # no token — uses your `gh` auth
```
Both scripts re-verify the published artifact's sha256 before submitting, and
@@ -304,7 +309,7 @@ Owner assignments and rotation reminders live with the team (issue tracker).
## 6. Post-release verification
- [ ] GH Release: 47 assets, sizes sane, and the asset-name set matches the
- [ ] GH Release: 49 assets, sizes sane, and the asset-name set matches the
previous release (see the `diff` one-liner in BUILDING.md § Release
runbook). macOS is arm64-only — do **not** look for an Intel DMG.
- [ ] Maven Central: `quartz:<version>` resolves (allow tens of minutes of
@@ -325,3 +330,112 @@ Owner assignments and rotation reminders live with the team (issue tracker).
see § 4); UnifiedPush still works on an `fdroid` build.
If anything ships broken, see [`BUILDING.md` § Incident response](BUILDING.md#incident-response).
---
## 7. Crash reports & retrace
Release builds are minified **and obfuscated** (they have to be: Play Console
drops apps whose DEX is under 25% optimized or obfuscated out of store surfaces
— see `amethyst/proguard-rules.pro` for the whole story). So a raw stack trace
from a release build looks like this:
```
java.lang.IllegalStateException: something blew up
at onh.B(r8-map-id-12c710927a584543dbe1e2e867db95460bc44482efe53283f86798648c1cfc00:7)
```
That is not lost information, it is encoded information. Paste the report in and
run it:
```bash
scripts/retrace.sh crash-report.txt
pbpaste | scripts/retrace.sh # or straight off the clipboard
```
Nothing else to supply. A report's first line names its own build —
`java.lang.IllegalStateException: 1.16.0-PLAY` — so the script resolves the tag
(`v1.16.0`) and the flavor (`PLAY` → `googleplay`), downloads that release's
mapping asset and caches it. For a bare stack trace with no such header, name
the build yourself with `--release v1.16.0 --flavor play`; for a build you made
locally, pass its `mapping.txt` directly.
```
java.lang.IllegalStateException: something blew up
at androidx.compose.foundation.text.input.TextFieldCharSequence.getText(TextFieldCharSequence.kt:58)
at androidx.compose.foundation.text.input.TextFieldState.getText(TextFieldState.kt:146)
at com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel.onMessageChanged(ShortNotePostViewModel.kt:1727)
```
Note that retrace gave back **three** frames where the crash reported one: R8
had inlined two of them. That is worth internalising — it is the reason a raw
trace's line number cannot be trusted even in the pre-obfuscation builds, where
optimization was already inlining. Retracing is not a tax obfuscation imposed;
it is how you read an optimized build at all.
**The wrong mapping is worse than none** — it produces confident, wrong names.
So the script refuses to guess: the `r8-map-id-<hash>` in the trace *is* the
`pg_map_id` header of the mapping that built it, and the two are compared before
anything is printed:
```
error: this mapping did not build this report.
report: deadbeef...
mapping: 12c71092... (amethyst-googleplay-mapping-v1.16.0.txt.gz)
```
`--force` overrides if you really mean it. (`googleplay` vs `fdroid` matters —
the two flavors are separate R8 runs with different mappings.)
**Per channel:**
| Where the report came from | What to do |
|---|---|
| Play Console / Android vitals | Nothing. AGP embeds the mapping in the `.aab` (`BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map`), so Play deobfuscates automatically. |
| A NIP-17 DM from the in-app crash reporter, a GitHub issue, F-Droid, Zapstore, Accrescent | `scripts/retrace.sh <report>` — it reads the build off line 1 and fetches the mapping. |
| A build you made locally | `scripts/retrace.sh amethyst/build/outputs/mapping/<variant>/mapping.txt report.txt` |
`scripts/retrace.sh` downloads the R8 version named in the mapping's own header
from Google's Maven and caches it, so it needs no pinned tooling and keeps
working across AGP bumps. It needs no `gh` auth either — release assets on a
public repo are plain HTTPS downloads.
Two details of the report format in `ReportAssembler` exist for this and should
not be "tidied" away: the headline carries the **fully qualified** exception
class (a bare `simpleName` obfuscates to `a`, which retrace cannot resolve
because it has no package), and stack frames are written as ` at <frame>`
(retrace only rewrites frames it recognises, and it recognises them by the
leading `at`). The script repairs the missing `at` on reports from older builds,
but new reports should not need repairing.
**Do not delete mapping assets from old releases.** They are the only copy —
CI's are gone when the job ends, and a mapping cannot be regenerated after the
fact (it would need a bit-identical rebuild, and R8's renaming is not stable
across runs).
### Did obfuscation break anything?
R8 cannot see reflection, so nothing in the build tells you that a keep rule
stopped matching. `tools/r8-verify/reflection-contract.txt` lists every place
something outside the DEX resolves a name at runtime — JNI symbols, Jackson DTO
field names, enum constants persisted in DataStore, WorkManager's stored worker
class names, the Cast `OptionsProvider` named in a manifest `<meta-data>` value
— and the release workflow asserts each one against what R8 actually emitted,
for both flavors, before any asset is collected:
```bash
python3 tools/r8-verify/verify_reflection_contract.py \
amethyst/build/outputs/mapping/playRelease/
```
Run it on any local minified build too. It takes under a second and needs no
device.
**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 silently.
What this does *not* cover is reflection nobody wrote down. For that the honest
controls are a staged Play rollout (the crash reporter retraces itself now, so
a break is legible within hours) and exercising NIP-47 wallet connect, NIP-46
bunker login, Tor, scheduled posts and a settings round-trip on a minified
build before shipping.
+177 -5
View File
@@ -67,6 +67,27 @@ afterEvaluate {
}
}
// Every ABI we split the APK for, and therefore every ABI that needs its own copy of each
// library we build and commit ourselves under src/main/jniLibs/. The lists drifted once: the
// splits shipped four ABIs while Arti was built for two, so the armeabi-v7a and x86 APKs
// installed and ran with the dependencies' native libraries all present (secp256k1's JNI ships
// every ABI) and Tor alone dead for the life of the install. `verifyNativeAbis` below keeps
// them in step.
val shippedAbis = listOf("x86", "x86_64", "arm64-v8a", "armeabi-v7a")
// The libraries that guard covers, and how to rebuild one when it is missing. Both are built
// from source by tools/ rather than pulled prebuilt, so both can go missing the same way — and
// a QR scanner that cannot load is as silently broken on that install as a dead Tor.
val committedNativeLibs =
mapOf(
"libarti_android.so" to { abi: String, triple: String ->
"./tools/arti-build/build-arti.sh --target=$triple"
},
"libzxingcpp_android.so" to { abi: String, _: String ->
"./tools/zxing-cpp-build/build-zxingcpp.sh --abi $abi"
},
)
android {
namespace = "com.vitorpamplona.amethyst"
compileSdk =
@@ -74,6 +95,33 @@ android {
.get()
.toInt()
// Packaging toolchain: AGP runs the NDK's llvm-strip over everything that
// lands in jniLibs — our committed libarti_android.so included — so the
// NDK revision is a build input for the APK, not just for whoever compiles
// Arti. Left unset it silently follows AGP's own default (28.2.13676358 on
// AGP 9.4.0), which moves with every AGP bump and is a different toolchain
// from the one that produced the .so, while a machine with no NDK at all
// packages the library unstripped ("Unable to strip the following
// libraries") — three different APKs from the same source, which is
// exactly what F-Droid's rebuild verification cannot have.
//
// Read straight from the pin rather than copied into the version catalog:
// the two can then never drift, and bumping ANDROID_NDK_VERSION (which also
// means rebuilding the .so files) moves the packaging toolchain with it.
// That one file is the repo's only NDK pin -- tools/arti-build/build-arti.sh
// and tools/zxing-cpp-build/build-zxingcpp.sh read it too, so every
// committed .so is produced and stripped by the same revision. It lives
// under tools/arti-build for history; it is not Arti's alone. See
// tools/arti-build/README.md → "Reproducible builds".
ndkVersion =
providers
.fileContents(layout.settingsDirectory.file("tools/arti-build/ANDROID_NDK_VERSION"))
.asText
.orNull
?.trim()
?.takeIf { it.isNotEmpty() }
?: error("tools/arti-build/ANDROID_NDK_VERSION is missing or empty — it pins the NDK that strips src/main/jniLibs")
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk =
@@ -89,7 +137,7 @@ android {
.get()
.toInt()
versionName = generateVersionName(libs.versions.app.get(), rootDir)
buildConfigField("String", "RELEASE_NOTES_ID", "\"8fce45589ea44df75e828a04c7d70bb4fabedd6ffc1946a920b2f0c7c990ff9f\"")
buildConfigField("String", "RELEASE_NOTES_ID", "\"f7914e7a7e293988485439eb2bea29c09c388d54c452c4a19f89e106dbf1969e\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -258,7 +306,7 @@ android {
abi {
isEnable = !disableAbiSplits
reset()
include("x86", "x86_64", "arm64-v8a", "armeabi-v7a")
include(*shippedAbis.toTypedArray())
isUniversalApk = !disableUniversalApk
}
}
@@ -295,6 +343,33 @@ android {
resources {
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib")
}
jniLibs {
// Reproducible builds, part two: ship the Arti library exactly as
// tools/arti-build produced it. Its Cargo release profile already
// strips it (no .symtab, no .debug_*), so AGP's
// `llvm-strip --strip-unneeded` pass has nothing left to remove — but it
// still rewrites the file: llvm-strip rebuilds .comment, the section that
// records the rustc / clang / lld version stamps, which measurably changes
// 273 bytes on arm64-v8a. That made the packaged bytes a function of
// whichever NDK did the stripping, so the .so in an APK could never be
// compared against the committed, independently reproducible one.
// Excluding it from the strip step costs nothing in size (there are no
// symbols to drop) and makes that comparison exact. Dependency .so files
// are still stripped, with the NDK pinned by ndkVersion above.
keepDebugSymbols += "**/libarti_android.so"
// Same guarantee for the QR decoder, for a different reason. Unlike Arti's, this
// library is *not* currently rewritten by AGP's pass -- verified by running the
// pinned NDK's `llvm-strip --strip-unneeded` over the committed file and getting
// identical bytes -- because tools/zxing-cpp-build strips it with that very same
// llvm-strip, which makes a second pass idempotent. That idempotence is a property
// of one NDK revision, though, and reading it back from the APK should not depend
// on a strip pass staying a no-op across bumps. Excluding it makes
// `unzip -p app.apk lib/<abi>/libzxingcpp_android.so | sha256sum` match
// src/main/jniLibs by construction, at no size cost.
keepDebugSymbols += "**/libzxingcpp_android.so"
}
}
lint {
@@ -305,8 +380,9 @@ android {
unitTests.isReturnDefaultValues = true
// Lets TorArtiNativeIntegrationTest's System.loadLibrary("arti_android")
// find the desktop-host build of our Arti JNI shim. The Android .so
// variants live in src/main/jniLibs/{arm64-v8a,x86_64}/ and are loaded
// on-device — this Linux x86_64 .so is just for JVM unit-test runs.
// variants live in src/main/jniLibs/<abi>/ — one per ABI in [shippedAbis]
// — and are loaded on-device; this Linux x86_64 .so is just for JVM
// unit-test runs.
// -Pamethyst.arti.integration=true opts the (slow, network-dependent)
// tests in; see TorArtiNativeIntegrationTest.kdoc.
unitTests.all { test ->
@@ -317,10 +393,103 @@ android {
project
.findProperty("amethyst.arti.integration")
?.let { test.systemProperty("amethyst.arti.integration", it.toString()) }
// Opts QrCorpusBaselineTest into rewriting the QR decode corpus under
// src/androidTest/assets/qr. Off by default so an ordinary run never dirties
// the working tree; Gradle forks the test JVM, so -D alone would not reach it.
project
.findProperty("amethyst.qr.corpus.export")
?.let { test.systemProperty("amethyst.qr.corpus.export", it.toString()) }
}
}
}
// Every ABI split must carry Arti, or it ships an APK that is whole except for
// Tor. Nothing else catches that: AGP happily assembles a split out of whatever
// .so files the dependencies provide, the APK installs and runs, and the gap
// only surfaces at System.loadLibrary time on a user's device — where
// TorManager's flow swallows the UnsatisfiedLinkError and leaves the status Off
// forever. Checked at build time instead, against the same list the splits use.
val verifyNativeAbis =
tasks.register("verifyNativeAbis") {
group = "verification"
description = "Checks that every ABI in the APK splits has each committed native library, built for that architecture."
val jniLibs = file("src/main/jniLibs")
val abis = shippedAbis
val libs = committedNativeLibs
// Per ABI: the Rust target triple (so an Arti failure names the exact build command) and
// the ELF identity every library must have — 32/64-bit class (header byte 4) and
// e_machine (bytes 18-19, little-endian on every Android ABI we ship). Existence alone is
// not enough: a truncated file, an empty placeholder, or arm64's .so copied into x86/ all
// load as nothing on device, which is the same silent failure this task exists to prevent
// — and unlike a missing file, those look fine in git.
val expected =
mapOf(
"arm64-v8a" to Triple("aarch64-linux-android", 2, 0xB7),
"x86_64" to Triple("x86_64-linux-android", 2, 0x3E),
"armeabi-v7a" to Triple("armv7-linux-androideabi", 1, 0x28),
"x86" to Triple("i686-linux-android", 1, 0x03),
)
doLast {
val bitness = mapOf(1 to "32-bit", 2 to "64-bit")
val problems = mutableListOf<Triple<String, String, String>>()
libs.keys.forEach { libName ->
abis.forEach { abi ->
val lib = File(jniLibs, "$abi/$libName")
val want = expected[abi]
val header = ByteArray(20)
val read = if (lib.isFile) lib.inputStream().use { it.read(header) } else -1
val problem =
when {
!lib.isFile -> "no $libName"
want == null -> "no expected ELF identity recorded for this ABI"
read < header.size ||
header[0] != 0x7F.toByte() ||
header[1] != 'E'.code.toByte() ||
header[2] != 'L'.code.toByte() ||
header[3] != 'F'.code.toByte() -> "not an ELF file (truncated or corrupt)"
header[4].toInt() != want.second ->
"${bitness[header[4].toInt()] ?: "unknown-class"} ELF, expected ${bitness[want.second]}"
else -> {
val machine = (header[18].toInt() and 0xFF) or ((header[19].toInt() and 0xFF) shl 8)
if (machine != want.third) {
"built for ELF machine 0x%02x, expected 0x%02x".format(machine, want.third)
} else {
null
}
}
}
if (problem != null) problems += Triple(libName, abi, problem)
}
}
if (problems.isNotEmpty()) {
throw GradleException(
buildString {
appendLine("Committed native libraries are missing or wrong for ${problems.size} (library, ABI split) pair(s):")
problems.forEach { (libName, abi, problem) -> appendLine(" $libName / $abi: $problem") }
appendLine("Those APK splits would install with that library permanently unavailable.")
appendLine("Rebuild them:")
problems.forEach { (libName, abi, _) ->
val triple = expected[abi]?.first ?: "<add the Rust target for $abi>"
val rebuild = libs[libName]?.invoke(abi, triple) ?: "<no rebuild command recorded for $libName>"
appendLine(" $rebuild")
}
append("…or drop the ABI from `shippedAbis` in amethyst/build.gradle.kts.")
},
)
}
}
}
tasks.named("preBuild") {
dependsOn(verifyNativeAbis)
}
// androidx.appfunctions-compiler runs in a per-module mode by default,
// emitting only the dispatcher Kotlin code. The aggregator that builds
// the `app_functions.xml` asset (which the system reads to discover our
@@ -504,8 +673,11 @@ dependencies {
implementation(libs.accompanist.permissions)
// For QR generation
// ZXing core encodes the QR codes we display. Decoding is zxing-cpp, which we build
// from source ourselves -- see tools/zxing-cpp-build -- rather than pulling a prebuilt
// AAR nobody in this tree could verify; the .so lives in src/main/jniLibs and its
// Kotlin wrapper is vendored at src/main/java/zxingcpp.
implementation(libs.zxing)
implementation(libs.zxing.embedded)
// OpenStreetMap tiles for road event location maps (kind 1315/1316)
implementation(libs.osmdroid.android)
@@ -0,0 +1,405 @@
# QR Reader Overhaul
**Status:** phases 1-5 implemented (see §7 for what shipped and what did not); phase 0 outstanding
**Goal:** make scanning a QR code in Amethyst fast and near-certain — codes that are small,
far, dim, glossy, tilted, inverted, on a screen, or already sitting in the gallery should all
resolve on the first try, and a code the app *can't* route should say so instead of silently
dropping the user back where they started.
**Scope:** the reader (`amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt` and the four
screens that call it), plus a short second section on the *display* side, because half of
"scan this npub" is how legibly we draw the code for the other phone.
---
## 1. What we have today
`SimpleQrCodeScanner` is 30 lines that hand the whole job to
[zxing-android-embedded](https://github.com/journeyapps/zxing-android-embedded) 4.3.0 via
`ScanContract`, which launches its own `CaptureActivity`:
```kotlin
ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt(stringRes(id = Res.string.point_to_the_qr_code))
setBeepEnabled(false)
setOrientationLocked(false)
addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)
}
```
Four call sites:
| Call site | Payload expected |
| --- | --- |
| `ShowQRScreen.kt:182` (`NIP19QrCodeScanner`) | any `nostr:` / NIP-19 entity → `Route` |
| `KeyTextField.kt:106` | `nsec` / `ncryptsec` / bunker login |
| `AddNwcWalletScreen.kt:207`, `AddClinkDebitWalletScreen.kt:172` | `nostr+walletconnect://` |
| `Nip46SignerScreen.kt:174` | `bunker://` |
`libs.zxing` (ZXing **core**) is used only for *encoding* (`QrCodeDrawer.kt`,
desktop `QrCodeCanvas.kt`). The decoder we actually run is the one bundled inside
zxing-android-embedded.
### 1.1 Why it is hard to scan — verified causes
Each of these was checked against the library's source (4.x `master`) or our own code, not
recalled:
1. **It is a Camera1 app.** `com.journeyapps.barcodescanner.camera.CameraManager` imports
`android.hardware.Camera`. Everything downstream inherits Camera1's limits: legacy-HAL
preview sizes, no per-frame 3A control, and no zoom API wired up at all.
2. **Autofocus is a 2-second timer, not continuous.** `CameraSettings` defaults to
`focusMode = FocusMode.AUTO` (`continuousFocusEnabled = false`), and `AutoFocusManager`
re-triggers `Camera.autoFocus()` on `AUTO_FOCUS_INTERVAL_MS = 2000L`. Between triggers the
frame can stay out of focus for up to two seconds — this is the "hold it still… still…
still…" feel, and it is fatal for close-up codes where the lens needs to rack to macro.
3. **`MIXED_SCAN` halves our decode rate.** We pass `Intents.Scan.MIXED_SCAN`, which selects
`MixedDecoder`, whose `toBitmap()` flips a boolean and inverts *alternate frames*. So for a
normal dark-on-light QR — i.e. essentially every code we meet — half of all frames are spent
decoding an inverted image that can never match.
4. **No zoom, at all.** Not pinch, not a button, not automatic. A code on a laptop screen across
a desk, or a small printed code on a sticker, simply never resolves enough modules.
5. **No discoverable torch.** The stock `CaptureActivity` layout has no torch button;
`DecoratedBarcodeView` only maps it to the volume keys. In a bar or a meetup hallway — the
exact place people swap npubs — there is no way to light the code.
6. **The decode is cropped.** `BarcodeView` sets `decoderThread.setCropRect(getPreviewFramingRect())`,
and `CameraPreview` defaults to `marginFraction = 0.1d`, so anything outside the centred
viewfinder box is discarded even though the user can see it in the preview.
7. **One decode attempt per frame, no retry ladder.** `DecoderThread.decode()` builds a single
`HybridBinarizer` over the cropped luminance and calls the reader once. No `TRY_HARDER`, no
rotation retry, no multi-scale, no denoise. ZXing-Java's detector needs three clean finder
patterns; glare, a crease, or a ~15° tilt past its tolerance and the frame is simply lost.
8. **It leaves the app.** `ScanContract` starts a separate activity with its own theme — no
Material3, no edge-to-edge, a visible cold-start hitch, and `setOrientationLocked(false)`
means rotating the phone recreates that activity and restarts the camera mid-scan.
9. **Failure is silent and ambiguous.** `NIP19QrCodeScanner` maps both "user cancelled" and
"decoded fine, but `uriToRoute` returned null" to `onScan(null)`, and `ShowQRBody` reacts by
flipping `presenting = true`. The user sees the QR screen again with no message. A decoded
but unroutable payload (a bare hex pubkey, an `nsec1…`, a plain `https://` link, another
app's code) is indistinguishable from "the camera never read it" — which is very likely part
of why the reader *feels* broken even in cases where the decode succeeded. This is the same
complaint as [#417](https://github.com/vitorpamplona/amethyst/issues/417), which was closed
in 2023 but is still the current behaviour.
10. **No way in except the live camera.** You cannot scan a QR you were *sent* — a screenshot, a
photo in the gallery, an image in a DM — nor paste one from the clipboard. Today the only
path is to get a second screen to display it and point the phone at it.
### 1.2 One happy accident
`amethyst/build.gradle.kts:622-626` already declares `camera-core`, `camera-camera2`,
`camera-lifecycle`, `camera-view` and `camera-extensions`, and **nothing in the repo imports
`androidx.camera`** (grep across `.kt`/`.xml` returns only a `PreviewView` line in the generated
baseline profile). CameraX is already paid for in APK size and in the licence audit; we just
haven't used it. This plan finally does.
---
## 2. Target architecture
Replace the external-activity scanner with an in-app Compose screen:
```
Route.QrScanner ──► QrScannerScreen (Compose, Material3, edge-to-edge)
├─ CameraX Preview (PreviewView) ─────┐
├─ CameraX ImageAnalysis ──► BarcodeDecoder ──► ScanResult
├─ ScannerOverlay (cutout, hit boxes, torch, zoom, gallery)
└─ ScanOutcomeSheet (routes, or explains why it can't)
│
gallery / clipboard / shared image ──► BarcodeDecoder.decode(Bitmap) ┘
```
**`BarcodeDecoder`** is a small interface with two entry points —
`decode(ImageProxy): List<ScanResult>` and `decode(Bitmap, cropRect, rotation): List<ScanResult>`
— so the camera plumbing, the UI, and the tests are all independent of which engine sits behind
it.
### 2.1 Decoder choice: `io.github.zxing-cpp:android`
**Recommended: `io.github.zxing-cpp:android:3.1.1`, replacing `com.journeyapps:zxing-android-embedded`.**
Licence (per CLAUDE.md, verified against the published POM at
`repo1.maven.org/maven2/io/github/zxing-cpp/android/3.1.1/android-3.1.1.pom`):
**Apache License 2.0 → permissive → OK, proceed.** No GPL family, no linking-exception question.
Why it is the right engine here:
- Its `Options` are exactly the knobs our failure modes need — `tryHarder`, `tryRotate`,
`tryInvert`, `tryDownscale`, `tryDenoise`, `binarizer`, `maxNumberOfSymbols`. In particular
`tryInvert` does inverted detection **inside one call as a fallback pass**, which is what
`MIXED_SCAN` was reaching for without throwing away half our frames.
- `read(image: ImageProxy)` reads the Y plane straight out of the CameraX buffer
(`planes[0].buffer`, `rowStride`, `cropRect`, `rotationDegrees`) — no YUV→RGB copy, no
`Bitmap` per frame.
- Every `Result` carries a `Position` (four corners + orientation), which we need for the
tappable multi-code overlay and for the auto-zoom heuristic.
- `sequenceId` / `sequenceIndex` / `sequenceSize` give us Structured Append (multi-part QR) for
free — the door to large payloads later.
- `minSdkVersion 21` (from the AAR manifest) against our `minSdk = 26`. Fine.
Cost: a prebuilt `.so` per ABI — `arm64-v8a` 1.75 MB, `armeabi-v7a` 1.23 MB, `x86` 1.85 MB,
`x86_64` 1.82 MB uncompressed. With `splits.abi` already enabled, an arm64 APK grows by roughly
0.8 MB compressed, partly offset by dropping zxing-android-embedded. We already ship prebuilt
JNI (`secp256k1-kmp-jni-android`), so F-Droid precedent exists — but **confirm with the F-Droid
packaging before landing**, since a new prebuilt binary is the kind of thing their build
metadata cares about.
**Why not ML Kit barcode scanning:** it is proprietary and GMS-shaped, so it could only ship in
the `play` flavor and `fdroid` would need a FOSS decoder anyway — meaning we'd build the
zxing-cpp path regardless and then maintain two. Because `BarcodeDecoder` is an interface, an
ML Kit implementation in `amethyst/src/play/` (the pattern `MLKitImageLabelService.kt` already
uses in both flavor trees) stays a cheap, later, measurement-driven option. Phase 0 exists to
decide whether it's ever needed.
**Fallback if the native lib is rejected:** keep `libs.zxing` (already present, ZXing-Java, zero
new bytes) behind the same interface. Phases 1, 3 and 4 below — the camera stack, the feedback
model, and image import — are decoder-independent and carry most of the win on their own.
---
## 3. Phases
### Phase 0 — Build the ruler before cutting (do this first)
Nothing here ships; it exists so every later claim is measured rather than asserted.
- [ ] Assemble a fixture corpus under `amethyst/src/androidTest/assets/qr/`: ~60 PNGs of real
Amethyst payloads (`npub`, `nprofile` with relay hints, `nevent`, `naddr`,
`nostr+walletconnect://`, `bunker://`, a Concord invite) rendered by our own
`QrCodeDrawer` and then degraded — gaussian blur, 8/15/30° tilt, perspective, 30 %/10 %
contrast, inverted, JPEG artefacts, partial glare, photographed-off-a-screen moiré, and
a "small in frame" set at 120/80/48 px across.
- [ ] `QrDecodeCorpusTest` (androidTest): decode every fixture with (a) today's ZXing-Java +
`HybridBinarizer` path and (b) zxing-cpp with the Phase 2 options. Report a pass rate per
category and per-image median decode time.
- [ ] Record the baseline numbers in this file. **Gate:** if zxing-cpp does not clearly beat
ZXing-Java on the degraded sets, drop §2.1 and keep ZXing-Java behind `BarcodeDecoder`.
### Phase 1 — In-app CameraX scanner (the structural fix)
- [ ] `BarcodeDecoder` interface + `ScanResult` (text, bytes, format, corner positions,
sequence info) in `amethyst/.../ui/screen/loggedIn/qrcode/decode/`.
- [ ] `QrScannerScreen` — `PreviewView` + `ImageAnalysis` bound to `LocalLifecycleOwner` via
`ProcessCameraProvider`, `STRATEGY_KEEP_ONLY_LATEST`, `OUTPUT_IMAGE_FORMAT_YUV_420_888`,
analysis resolution requested at 1280×720 with a 1920×1080 fallback via
`ResolutionSelector`. Analysis runs on a single background executor; results hop back to
the main thread through a `StateFlow`.
- [ ] Register `Route.QrScanner` in `Routes.kt` / `AppNavigation.kt` and move the four call
sites onto it. `SimpleQrCodeScanner` keeps its `(String?) -> Unit` signature as a thin
wrapper so the wallet/bunker/login screens change by one import, not a rewrite.
- [ ] Camera permission with `accompanist-permissions` (already a dependency, used by
`TakePicture.kt`): inline rationale, and an "Open settings" path when permanently denied —
today a denied permission just bounces the user out of `CaptureActivity` with no
explanation.
- [ ] Focus: rely on CameraX's continuous AF, plus tap-to-focus through
`PreviewView.meteringPointFactory` → `FocusMeteringAction` with AF+AE, auto-cancel on.
This alone removes cause §1.1.2.
- [ ] Remove `com.journeyapps:zxing-android-embedded` from `libs.versions.toml` and
`amethyst/build.gradle.kts`. **Keep `libs.zxing`** — encoding still needs it.
- [ ] Delete the decorative crop: analyse the full frame (or a ≥90 % ROI), and let the
viewfinder cutout be purely visual. Fixes §1.1.6.
### Phase 2 — Make each frame count
- [ ] Decoder options: `formats = {QR_CODE, MICRO_QR_CODE, RMQR_CODE}`, `tryInvert = true`,
`tryRotate = true`, `tryDownscale = true`, `binarizer = LOCAL_AVERAGE`,
`maxNumberOfSymbols = 5`. `tryHarder` on the still/import path always; on the live path
alternate a cheap pass with a `tryHarder`/`tryDenoise` pass every Nth frame so the frame
rate stays interactive.
- [ ] **Zoom.** Pinch-to-zoom (`CameraControl.setZoomRatio` driven by a
`TransformableState`) plus a 1×/2× quick toggle. Then auto-zoom: after ~1.2 s with no
decode, ramp the zoom 1.0 → 2.0 → back over a couple of seconds; if a code *is* decoded
but its `Position` spans less than ~25 % of the frame's short side, zoom so it fills
~60 % and re-read. Fixes §1.1.4, the single biggest "it just won't read" cause.
- [ ] **Torch.** A visible toggle in the overlay, plus auto-suggest: the analysis frame's Y plane
gives mean luminance for free — under a threshold for ~1 s, surface the torch button
prominently (never auto-fire it; that's rude in a bar and worse in a meeting).
- [ ] Dedupe: ignore an identical payload re-decoded within 1.5 s so a held-steady code doesn't
fire the handler repeatedly.
- [ ] Structured Append: accumulate parts keyed by `sequenceId`, show "2 of 3 captured", emit
once complete, time out after 30 s. Groundwork for large/animated payloads.
### Phase 3 — Tell the user what happened
This is the cheapest phase and probably the largest perceived improvement.
- [ ] Split today's single `null` into an explicit outcome type: `Cancelled`,
`PermissionDenied`, `Decoded(text) → Route`, and `Decoded(text) → unroutable`.
- [ ] On a decode: haptic tick (`HapticFeedbackType.LongPress`, matching `SwipeToDelete.kt` and
the wallet wizard) plus a brief highlight drawn over the code's four corners.
- [ ] On **unroutable** content, show a bottom sheet with the decoded text, what we think it is,
and actions — *Open link* for `http(s)`, *Copy*, *Search*, *Try again*. Closes the
ambiguity behind §1.1.9 and [#417](https://github.com/vitorpamplona/amethyst/issues/417).
- [ ] When ≥2 codes are in frame, draw a tappable box over each and let the user pick, instead
of silently taking whichever ZXing found first.
- [ ] Extract payload classification out of `MainActivity.uriToRoute` into a pure, JVM-testable
`classifyScannedPayload(text): ScannedPayload` (nostr entity / wallet connect / bunker /
nostrconnect / http(s) / lightning / unknown). `uriToRoute` keeps routing; the scanner
gets a testable "what is this?" answer, and a bare 64-char hex pubkey — issue #417's
original case — becomes trivial to accept.
### Phase 4 — Scan things that aren't in front of the camera
- [ ] **From the gallery:** a picker button in the overlay
(`ActivityResultContracts.PickVisualMedia`) → decode the bitmap with the full
`tryHarder + tryRotate + tryInvert + tryDenoise` option set, and on failure retry at
2× and 0.5× scale before giving up.
- [ ] **From the clipboard:** if the clipboard holds an image, offer "Scan copied image"; if it
holds text that `classifyScannedPayload` recognises, offer to use it directly. Covers the
overwhelmingly common "someone sent me a screenshot of their npub" flow.
- [ ] **Shared in:** accept `ACTION_SEND` with an `image/*` MIME type into the scanner, so
"Share → Amethyst" from a gallery or chat app resolves the code. (The manifest already has
an `ACTION_SEND` image target for new posts — this needs to be a distinct, explicitly
labelled entry, not a hijack of that one.)
### Phase 5 — The display side
Half of a scan is the code on the *other* screen.
- [ ] `ShowQRScreen` does **not** boost brightness, while `ShareNoteAsQrScreen.kt:238-249`
already does. Extract that into a `KeepScreenBrightAndOn()` composable (brightness 1f +
`FLAG_KEEP_SCREEN_ON`, restoring the previous value on dispose) and use it on both. On a
dim OLED in dark mode this is the difference between scannable and not.
- [ ] `QrCodeDrawer.kt` hardcodes `CornerRadius(20f)` for the finder patterns regardless of how
many pixels a module is — at small draw sizes that rounds a meaningful fraction of the
finder away. Scale the radius with module size (cap around 20 % of a module).
- [ ] Error correction is fixed at `ErrorCorrectionLevel.Q`. For long payloads (an `nprofile`
with two relay hints, a Concord invite) Q pushes the version up, so modules get smaller —
and small modules, not error correction, are what actually defeats a camera at arm's
length. Measure Q vs M across our real payload lengths on the Phase 0 corpus and pick per
length rather than globally.
- [ ] Verify the `.clip(QuoteBorder)` 15 dp rounding never eats into the 4-module quiet zone at
the sizes we actually render.
---
## 4. Testing
| Level | What |
| --- | --- |
| JVM unit | `classifyScannedPayload` over every payload we claim to accept, plus junk, plus the bare-hex and `nsec` cases; Structured Append accumulator (ordering, duplicates, timeout). |
| androidTest | `QrDecodeCorpusTest` (Phase 0) as a permanent regression gate — pass rate per degradation category, asserted against the recorded baseline so a decoder or option change can't quietly regress. |
| androidTest | Bitmap-import path end to end: fixture → `decode(Bitmap)` → `ScannedPayload`. |
| Manual matrix | Printed sticker at 10/30/60 cm; phone screen at 30/60/100 cm; laptop screen across a desk; dark room with and without torch; behind glossy glass; 15°/30°/45° tilt; two codes in frame; inverted (light-on-dark) code. Record pass/fail before and after. |
| Macrobenchmark | Time from tapping "Scan QR" to first preview frame — the current external-activity cold start is part of what the change should erase. |
## 5. Risks
- **APK size** — ~0.8 MB compressed on arm64 for the native decoder, partly returned by
dropping zxing-android-embedded. Phase 0's gate is what justifies it.
- **F-Droid** — a new prebuilt `.so` from Maven Central; check with the F-Droid packaging before
landing rather than after. The ZXing-Java fallback keeps this from being a dead end.
- **Camera device variance** — CameraX is far more uniform than Camera1, but zoom ratio ranges
and torch availability still vary; every control must degrade to hidden rather than broken.
- **Scope creep into the display side** — Phase 5 is deliberately last and independent.
- **Desktop** — `desktopApp` has no scanner today and this plan doesn't add one. If webcam
scanning is ever wanted, `BarcodeDecoder` + the payload classifier are the reusable halves;
the CameraX screen is not.
## 6. Out of scope
Animated / BC-UR multi-frame QR *generation*, NFC handoff, and any change to what the four
existing call sites do with a successful payload.
---
## 7. What shipped, and what did not
Implemented in this branch:
| Phase | State |
| --- | --- |
| 0 — measurement corpus | **Half done.** The corpus and the ZXing-Java baseline are built and measured (see §8); the zxing-cpp half needs a device and is written but unrun, so the gate is armed rather than passed. |
| 1 — in-app CameraX scanner | Done. |
| 2 — per-frame decode quality | Done. |
| 3 — explicit outcomes | Done. |
| 4 — gallery / clipboard import | Done, minus the `ACTION_SEND` share-in target. |
| 5 — display side | Done. |
### Deviations from the plan above
- **A dialog, not a `Route`.** §2 proposed registering `Route.QrScanner`. One of the four call
sites is `KeyTextField` on the *logged-out* login screen, which lives outside the navigation
graph entirely, so a route could not serve it. `QrCodeScannerDialog` is a full-screen
`Dialog` instead, which also let all four call sites keep their existing
`SimpleQrCodeScanner { }` shape — the diff at each is one import.
- **`ScanOutcome` instead of "close, then explain".** For the sheet in §3 to appear, the scanner
has to still be open when the caller decides it cannot use the payload. So the callback returns
`ScanOutcome.Handled` / `ScanOutcome.NotSupported` rather than `Unit`, and the camera keeps
running through the explanation.
- **No "found but too small" auto-zoom branch.** §2 listed zooming toward a code whose
`Position` spans too little of the frame. That branch is unreachable: if the code decoded, we
are done with it. Auto-zoom now only sweeps while *nothing* is decoding, which is the case
that actually fails.
- **`ACTION_SEND` image share-in not wired.** The manifest already has an `ACTION_SEND` image
target aimed at new posts; adding a second, distinctly-labelled one is a manifest and routing
change that belongs with its own testing rather than bolted onto this branch.
### Still to do
1. **Phase 0, retroactively.** Build the corpus, run `QrDecodeCorpusTest` on a device, record
the numbers here, and confirm the engine choice against them. Until that happens, "zxing-cpp
beats ZXing-Java on degraded codes" is a well-founded expectation, not a measurement.
2. **The manual matrix in §4.** None of it has been run — there is no camera in this
environment. Every camera-facing behaviour in phases 1, 2 and 4 (binding, focus, torch,
auto-zoom, the overlay's coordinate mapping, the photo picker) is compile-verified and
reasoned-through only.
3. **F-Droid packaging sign-off** on the new prebuilt `.so`, per §5.
4. **The ECC level question** in §5 — still open, and still wants the corpus to answer it.
---
## 8. Phase 0: the baseline
`QrCorpus` renders three payloads — an `npub`, an `nprofile` with relay hints, and an `nevent`
(69, ~330 and ~140 characters, so three different symbol versions) — at 4 pixels per module, then
degrades each one sixteen ways. Every degradation is expressed as a fraction of a *module*, so
changing the render scale cannot quietly re-tune the corpus's difficulty.
`QrCorpusBaselineTest` measures **ZXing-Java**, the decoder the old zxing-android-embedded scanner
used, over that corpus on the JVM. Measured 2026-09-15:
| category | ZXing-Java | what it stands for |
| --- | --- | --- |
| clean | 3/3 | sanity — if this ever fails the corpus is broken |
| blur | 3/3 | a quarter-module out of focus |
| blur-heavy | **0/3** | half a module out of focus |
| tilt15 / tilt30 / tilt45 | 2/3 each | held at an angle |
| perspective | **0/3** | seen off-axis — a code on a wall or table, photographed from the side |
| low-contrast | 3/3 | a dim screen |
| very-low-contrast | **0/3** | a very dim screen, or worn print |
| inverted | 3/3 | light-on-dark |
| glare | 3/3 | a highlight burning out one corner |
| moire | 3/3 | photographed off another screen |
| noise | 2/3 | sensor noise in poor light |
| far-3px | 3/3 | three pixels per module |
| far-2px | 2/3 | two pixels per module |
| far-1.5px | **0/3** | one and a half pixels per module |
| **TOTAL** | **31/48** | |
Read it as a map of where the old reader gave up. **Perspective is a total loss** — an off-axis
code, one of the most ordinary framings there is, was simply unreadable. So are heavy blur, very
low contrast, and anything under two pixels per module.
Two caveats on the number, both of which make 31/48 *flattering* to the old scanner:
- It decodes the **full image** and retries **inverted**. The shipped scanner cropped to a
viewfinder rect and alternated inversion across frames, so it had strictly fewer chances.
- It measures a decoder on a still. It says nothing about focus, zoom or torch, which is where
most of this branch's other work went.
So a win measured here is a floor on the real-world difference, not the whole of it.
**The gate is not yet passed.** `QrDecodeCorpusTest` runs zxing-cpp over the identical committed
images and fails if it reads fewer in any category. It needs a device. Until someone runs it, the
decoder swap rests on the reasoning in §2.1 — this section just means the measurement is now one
command away instead of unbuilt.
The corpus costs 776 KB in `amethyst/src/androidTest/assets/qr/` (test APK only, never shipped).
Regenerate it with:
```bash
./gradlew :amethyst:testFdroidDebugUnitTest --tests '*QrCorpusBaselineTest*' \
-Pamethyst.qr.corpus.export=true
```
@@ -0,0 +1,521 @@
# NIP-CC (Geocaching) — gap analysis for Quartz and Amethyst
Status: **steps 1–2 done** — the Quartz protocol package (`quartz/…/nipCCGeocaching/`) with its
tests, and the Amethyst read path: caches and found logs are stored, rendered, searchable, and
appear in the geohash/AroundMe feeds. Steps 3 onwards (cache detail screen, composing, the
verification QR flow, curation lists) are unstarted.
Spec: <https://github.com/nostr-protocol/nips/blob/master/CC.md> (merged into
`master`; listed in the NIPs README at line 117 and in the kind tables for
`7516`, `7517`, `37516`, `37517`). Read at 2026-09-17.
## 1. What the spec defines
| Kind | Class | Purpose |
|---------|-------------|-------------------------------------------------------------------------|
| `37516` | addressable | Geocache listing — name, location, difficulty/terrain/size, hint, … |
| `7516` | regular | Found log — claims a find, optionally carrying an embedded `7517` |
| `7517` | regular | Verification — **signed by the cache's verification key**, not the finder |
| `37517` | addressable | Curation list — ordered `a` references to `37516` listings |
| `1111` | existing | DNF / note / maintenance / archived logs, as NIP-22 comments on the cache |
Secondary mechanics: `n` type modifiers (`first-to-find`, `art`) with a
"one per category, ignore unknown" rule, an `F` tag that locks in the
first-to-find winner, a `mission` ("Key Quest") tag, and a client SHOULD to
ROT13 hints so they don't spoil.
### Confirmed with a grep, not from memory
At the time of the survey,
```
grep -rn "37516\|7517\|37517\|[Gg]eocach" --include=*.kt
```
returned only incidental hex substrings in unrelated tests — no geocaching code anywhere in
`quartz`, `commons`, `commonsUI`, `amethyst`, or `cli`. The Quartz half of that gap is now
closed; the rest stands.
## 2. Quartz — new code
New package `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipCCGeocaching/`,
laid out like `experimental/roadstr/` (event + `TagArrayExt` + `TagArrayBuilderExt`
+ `tags/`), which is the closest existing analogue: a geohash-anchored,
user-submitted place event.
```
nipCCGeocaching/
├── listing/
│ ├── GeocacheListingEvent.kt # 37516, BaseAddressableEvent + SearchableEvent
│ ├── TagArrayExt.kt / TagArrayBuilderExt.kt
│ └── tags/
│ ├── CacheNameTag.kt # "name" (required)
│ ├── DifficultyTag.kt # "D", 1..5, reject out-of-range
│ ├── TerrainTag.kt # "T", 1..5
│ ├── CacheSizeTag.kt # "S" -> enum micro|small|regular|large|other
│ ├── CacheTypeTag.kt # "t", defaults to `traditional` when absent
│ ├── TypeModifierTag.kt # "n" -> first-to-find | art (+ category)
│ ├── HintTag.kt # "hint"
│ ├── MissionTag.kt # "mission", first-wins if duplicated
│ ├── VerificationKeyTag.kt # "verification" (hex pubkey)
│ └── FirstToFindWinnerTag.kt # "F" (hex pubkey), first-wins
├── log/
│ ├── GeocacheFoundLogEvent.kt # 7516
│ └── tags/EmbeddedVerificationTag.kt # "verification" holding a 7517 as JSON
├── verification/
│ ├── GeocacheVerificationEvent.kt # 7517
│ ├── tags/FinderCacheTag.kt # the composite "a" tag — see §2.3
│ └── GeocacheVerificationValidator.kt # the 4-step check from the spec
├── curation/
│ ├── GeocacheCurationListEvent.kt # 37517, BaseAddressableEvent + SearchableEvent
│ └── tags/ThemeTag.kt, MapStyleTag.kt # "theme", "map"
├── comment/GeocacheLogTypeTag.kt # "t" on kind 1111: dnf|note|maintenance|archived
└── FirstToFindResolver.kt # winner selection + F lock-in precedence
```
### 2.1 Reuse — do not re-implement
The listing event is mostly existing tags wearing a new kind number:
| Spec tag | Reuse |
|-----------------|------------------------------------------------------------------------|
| `d` | `nip01Core/tags/dTag` |
| `g` | `nip01Core/tags/geohash/GeoHashTag` + `geohashes()` |
| `image` | `nip23LongContent/tags/ImageTag` |
| `r` | `nip51Lists/tags/RelayTag` (`"r"`) |
| `a` | `nip01Core/tags/aTag/ATag` (curation list + found log) |
| `title`, `description` (37517) | `nip51Lists/tags/TitleTag`, `.../DescriptionTag` |
| embedded 7517 | `Event.fromJson(...)` (same trick `LnZapEvent.zapRequest()` uses) |
| signature check | `Event.verifySignature()` in `nip01Core/crypto/EventExt.kt` |
| kind 1111 logs | `nip22Comments/CommentEvent` **as-is** — it already models `A/K/P` + `a/k/p` addressable root+parent (`rootAddress()`, `replyAddress()`, `hasRootAddress()`). Only the `t` log-type vocabulary is new. |
| required-tag gate | `containsAllTagNamesWithValues(REQUIRED_FIELDS)`, as `ClassifiedsEvent.isWellFormed()` does |
`GeoHashTag.geohashMipMap()` already emits the full precision ladder, but
**reversed and starting at 1 char**; the spec asks for precisions 3–9 in
coarse-to-fine order. Add a bounded variant (`geohashMipMap(min, max)`) rather
than publishing 1- and 2-character geohashes that are useless for proximity
search and noisy on relays.
### 2.2 Type modifiers need a category model, not a string set
Rule 2 ("at most one `n` per category, first occurrence wins") and rule 4
("ignore unrecognised values") mean `n` cannot be a flat `Set<String>`. Model it
as `enum class TypeModifierCategory { CLAIM_SEMANTICS, PRIZE_NATURE }` with
`first-to-find -> CLAIM_SEMANTICS`, `art -> PRIZE_NATURE`, and a parser that
keeps the first value per category and drops unknowns. Forward compatibility is
an explicit spec requirement, so unknown values must not make the event
unparseable.
### 2.3 Spec warts to handle defensively
1. **The `7517` `a` tag is not a NIP-01 `a` tag.** It is
`"<finder-pubkey-hex>:<geocache-naddr>"` — two colon-joined parts, where a
real `a` value is `kind:pubkey:d`. `Address.parse` will not parse it: a 2-part
value fails the `parts.size > 2` guard, falls through to the `naddr1` branch,
doesn't start with `naddr1` either, and returns null **after logging a
warning** — so nothing silently misreads it, but any generic `a`-tag consumer
that meets a `7517` will spam `AddressableId` warnings. It needs its own
`FinderCacheTag` parser, and the `naddr` half goes
through `nip19Bech32/entities/NAddress`. Worth an upstream issue; implement
the spec as written, tolerate the sane `kind:pubkey:d` form on read.
2. **`7517` is signed by a key the user does not own.** The finder scans a QR at
the cache carrying the verification *private* key, then signs a `7517` with
it. That cannot go through the account's `NostrSigner` — it needs an ephemeral
`NostrSignerInternal(KeyPair(privKeyFromQr))`. That private key must never
touch the Keystore, account state, or a log line. See §4.
3. **`created_at` is forgeable, so first-to-find ordering is advisory.** The spec
says so itself: earliest `created_at`, ties broken by ascending event `id`,
*until* the owner publishes an `F` tag — after which `F` wins unconditionally.
`FirstToFindResolver` should encode exactly that precedence and the UI should
mark a pre-`F` winner as provisional.
4. **`37515` is referenced but never defined.** The 37517 `a`-tag prose says
"kind 37516 or 37515". Accept both on read; only ever write `37516`.
5. **`NIP-GD.md` (Good Deed) does not exist.** The `mission` tag's cross-reference
is a dangling link (`raw.githubusercontent.com/.../NIP-GD.md` → 404). Implement
`mission` as free text; do not build a Good-Deed integration against a spec
that isn't published.
6. **`archived` is an `n`-modifier-shaped value living in `t`.** Owners archive a
cache by adding `["t", "archived"]` to the *listing*, while `t` on the listing
otherwise means cache *type* (`traditional`/`multi`/`mystery`) and `t` on a
*comment* means log type. Three meanings, one tag name — parsers must be
scoped per kind, not shared.
### 2.4 Registration points (each one is a real integration, not a formality)
- `quartz/utils/EventFactory.kt` — 4 new `KIND ->` branches. Without this the
events parse as bare `Event` and every accessor above is dead code.
- `quartz/kinds/KindNames.kt` — 4 `KindName(..., "CC")` entries.
- `quartz/nip50Search/SearchableKinds.kt` — add `7516`, `37516`, `37517` if they
implement `SearchableEvent`. **`SearchableKindsTest` sweeps kinds 0–65535 and
asserts this list is exactly the searchable set**, so a missed entry lands as a
failing test naming the number. `indexableContent()` for `37516` should be
name + content; the `hint` deliberately should *not* be indexed (searching for
a hint is spoiling).
- Tests under `quartz/src/commonTest/.../nipCCGeocaching/`: round-trip each kind
against the spec's own example JSON, the `D`/`T` range guards, the
one-`n`-per-category rule, the `F`-beats-`created_at` precedence, and a
verification event whose signature does *not* match the `verification` pubkey.
## 3. Amethyst — new code
Good news: the expensive parts already exist and were built for road events
(kind 1315/1316).
**Reuse as-is:**
- `amethyst/ui/note/creators/location/LocationPickerMap.kt`,
`LocationPreviewMap.kt`, `MapPinIcon.kt`, `GeohashLocationPickerDialog.kt` —
an osmdroid/OSM interactive map with markers, tap-to-place, and a geohash
picker dialog.
- `service/location/LocationState` + the `AroundMe` top-nav feed for "caches
near me".
- `commons/.../relayClient/geohash/FilterPostsByGeohash.kt` — adding
`GeocacheListingEvent.KIND` to `PostsByGeohashKinds` is a one-line change that
puts caches into the existing geohash feed.
- NIP-22 comment rendering and composing — DNF/note/maintenance logs are already
displayable threads once the root kind is known.
**New:**
- `commonsUI/.../ui/note/GeocacheCard.kt` — cache card (name, D/T/S badges,
type + modifier badges, distance, ROT13-toggle hint), modelled on
`RoadEventCard.kt`.
- `amethyst/ui/note/types/Geocache.kt` + dispatch branches in
`ui/note/NoteCompose.kt` (~line 1405/1450 is where `ClassifiedsEvent` and
`RoadEventReportEvent` branch) and in `ThreadFeedView.kt`.
- `LocalCache.kt` — consume the four kinds (the `is ClassifiedsEvent,` /
`is RoadEventReportEvent,` groups at ~3759/3933 are the template).
- Cache detail screen: map, logs tab, "Log a find" / "Log DNF" actions, and —
for a `first-to-find` cache with any valid verified find — the spec's
"render as effectively archived, hide find affordances, show the winner".
- Cache composer (owner side): map picker → geohash ladder, D/T/S pickers, hint,
optional verification keypair generation + QR export, mission.
- QR scan → `7517` signing flow (see §4).
- Curation list (37517) browse + detail, honouring `theme`/`map` as *defaults*
the user can override.
- ROT13 helper — **does not exist anywhere in the repo** (`grep -i rot13` is
empty). Small, belongs in `commons` next to the other text utils, with the
hint rendered rotated and a tap to reveal.
- Strings for every label above (`commonsUI` `composeResources`), plus any new
Material Symbol codepoints — which means running
`./tools/material-symbols-subset/subset.sh` and committing the regenerated
`.ttf`, or the icon renders as tofu.
## 3.5 Interoperability — checked against the network, not just the spec
Two other implementations exist: **treasures.to** (the originating client, whose caches are the
spec's examples) and **Lightning Piggy** (`BenGWeeks/lightning-piggy-mobile`, whose
`src/services/nostrPlacesService.ts` is a readable second opinion). A corpus of real events was
pulled off relay.damus.io / nos.lol / relay.primal.net / nostr.wine with `amy fetch` and is
pinned at `quartz/src/commonTest/resources/nipcc.interop.json`; `NipCCInteropTest` parses it.
What the network confirmed:
- **The `g` ladder really is 3..9.** All 60 sampled listings published exactly that band, and
Lightning Piggy's builder loops `for (n = 3; n <= 9; n++)`. The bounded `geoMipMap` overload
matches.
- `a` on a found log is a plain `37516:<pubkey>:<d>`; the composite `<finder-hex>:<naddr>` on an
embedded 7517 is exactly as specified. Both parse.
- `archived` really does appear in `t` alongside cache types on live listings, and real `F`
lock-ins and `n` modifiers exist. The split parsers handle them.
- Listings carry `client`, `expiration`, NIP-32 `L`/`l`, `content-warning` and payout hints we
model nothing for; ignoring them is harmless.
**The one real divergence: `hint`.** NIP-CC contradicts itself — the tag table says "plaintext"
and the example is plaintext, while the Clients section asks for ROT13 — and the network split
down the middle: of 34 sampled hints, **17 plaintext and 17 ROT13**, consistent within each
author but not across them. Either fixed reading spoils half the caches in the world. So
`HintObfuscation` picks per hint, showing whichever of the two rotations scores worse against
English letter frequency, and the card *toggles* rather than revealing once, so a wrong guess
costs a tap instead of the hint.
Worth raising upstream: the spec should say which form goes on the wire. Until it does, a writer
has to pick, and this code writes ROT13 — the reference client's choice, and the one that fails
safe (a reader assuming plaintext sees noise, not the answer).
Note the trap if anyone revisits the heuristic: counting vowels is backwards. ROT13 maps `n→a`,
`r→e`, `h→u`, `b→o`, so English ciphertext usually has *more* vowels than its plaintext.
### Rendering, compared kind by kind
Read against Lightning Piggy's screens (`CacheDetailSheet`, `HuntRailCard`,
`HuntPiggyDetailScreen`, `HuntRecentFindsSection`). What their UI does that ours did not:
- **The hint shows no text until tapped** — "Stuck? Tap to reveal the hint", never the encoded
form. Adopted: a wall of ROT13 reads as corruption, and with nothing rendered before the tap
the hint heuristic can no longer spoil anything, only mislabel after an explicit ask.
- **The cache photo leads the card.** 87 `image` tags across 60 sampled listings; both their rail
card and detail screen render it. We parsed `image` and rendered nothing. Adopted (first image
only; the rest belong on a detail screen).
- **A found-log row names the cache it is about.** Ours said "Found it!" and nothing else — the
one thing a reader already assumed. Adopted, plus the log's own photo (8 of 60 carry one). The
listing was already being loaded to validate the proof, so the name is free.
Where we deliberately differ, or lead:
- Our feed card carries the D/T/S and modifier badges; their rail card is name + kind + distance
and puts the chips on a detail sheet. Amethyst's feed cards are richer by house style
(cf. `RoadEventCard`), so this stays.
- They read `t` as one value, so an archived cache renders "archived" *as its cache type*. Our
split parser keeps the type and adds an Archived badge.
- They parse no `n` modifiers, no `F`, no `mission` and no `verification`; there is no reference
rendering for first-to-find, art, Key Quest or verified finds, and nobody validates a 7517.
Ours is the first — worth saying out loud, because it means those four have been checked
against the spec and against real events, but not against another client's behaviour.
- Neither client renders the kind 1111 `dnf`/`maintenance` log type. They have a builder and no
reader, exactly as we do. A shared ecosystem gap rather than a divergence.
- NIP-40: they stamp `expiration` on every listing (a year by default) and drop expired caches.
Amethyst honours NIP-40 globally in `CachePruner.pruneExpiredEvents`, so no per-kind work —
though pruning is periodic rather than render-time, which is true of every kind in the app.
### The card's visual hierarchy
The first card was a faithful dump of the tag list: a full-width **square** map (the map slot
never overrode `LocationPreviewMap`'s `aspectRatio = 1f`) immediately followed by a 16:9 photo,
then up to **nine** chips at identical weight, and no distance. A real cache off the relays —
*Treasure Troll's Trunk*, which carries every modifier the spec defines — lit up all nine.
Rebuilt around the three questions a reader actually has:
- **One hero.** Photo when there is one, with the map demoted to a 76dp corner inset that still
answers "where"; the map alone at 16:9 when there is no photo. `GeocacheMap` gained an
`aspectRatio` parameter so the card, not the host, decides the shape — the same slot now serves
both the wide hero and the square inset.
- **Ratings are a line.** `Traditional · Regular · D1 · T1` in one quiet row instead of four
chips, which is what Lightning Piggy's sheet does.
- **Colour only where it is rare.** Gold for an unclaimed first-to-find, green for verified
finds, muted for claimed and archived. The gold chip is suppressed once a cache is claimed — a
prize nobody can win should not glitter. Nine chips became four. Tints are backgrounds, not
text colours: the theme's amber is unreadable as text on a light ground, `onSurface` over a
wash reads in both.
- **The name is a title** that wraps to two lines, not a one-line pill clipped over a map.
- **Distance, top right.**
On distance: it reads `geolocationFlow().value` rather than collecting it. That flow is
`SharingStarted.WhileSubscribed`, so collecting from a feed card would switch the GPS on for
anyone who merely scrolled past a geocache — a real battery and privacy cost, paid silently, for
one line of text. Its initial value is the last cached fix, so this costs nothing and yields a
distance whenever something else (Around Me, the location picker) has already asked. The trade
is that it does not update as the reader walks; a card in a feed is not a compass, and the detail
screen is where a live fix belongs. Rounding is deliberately coarse (metres up close, one decimal
to 10km, whole km beyond) because Amethyst holds only `ACCESS_COARSE_LOCATION`.
## 4. Security review items
These are the parts worth a careful reviewer, not the event codecs:
1. **Verification private keys from QR codes are attacker-controlled input.**
They arrive by scanning an object in the physical world. Treat the scanned
value as untrusted: validate it is a 32-byte secp256k1 scalar, use it for one
ephemeral signature, never persist it, never log it, and never route it
through `NostrSignerInternal` instances that outlive the call.
2. **A `7517` proves presence, nothing else.** Validation must check all four
spec steps — signature valid, signer == the listing's `verification` pubkey,
finder pubkey in the `a` tag == the 7516 author, and the naddr resolves to
*this* cache. Skipping step 2 or 3 lets anyone replay someone else's
verification into their own log.
3. **Cache locations are precise real-world locations of the user.** Publishing a
9-character geohash is ~5m. The composer must make it obvious that this is
public, and the "log a find" flow must not attach the *finder's* location.
4. **Hints/missions are untrusted remote text** — same rendering rules as any
other user content (no auto-linkifying into a privileged surface).
## 5. Suggested sequencing
1. ~~Quartz protocol package + tests + the three registration files.~~ **Done** — 79 tests in
`quartz/src/commonTest/…/nipCCGeocaching/`, plus the three new rows in the
`indexable-content.golden` fixture and the `searchable-kinds.md` table. The layout follows
nip88Polls: a folder per subject, each with its event, `TagArrayExt`, `TagArrayBuilderExt`
and `tags/`.
2. ~~Read path in Amethyst.~~ **Done** — `GeocacheCard`/`GeocacheFoundLogCard` in `commonsUI`
(map-hero slot, D/T/S and modifier badges, ROT13 hint with tap-to-reveal), `Geocache.kt`
wrappers in `amethyst/` supplying the osmdroid `LocationPreviewMap`, `LocalCache` consume +
`computeReplyTo`, dispatch in `NoteCompose` and `ThreadFeedView`, the one-line
`PostsByGeohashKinds` addition, and the search window (`RenderableKinds` + a `kind:geocache`
alias).
Two things worth knowing about how it was wired:
- **The found-log proof badge is computed, never assumed.** A `verification` tag is a string
until it has been checked against the listing's key, the log's own author and the cache it
claims, so `RenderGeocacheFoundLog` loads and observes the listing and runs
`GeocacheVerificationValidator`. Until the listing arrives the state is `UNKNOWN` and the
card shows no badge — never an optimistic one. A verification that fails the check renders
as a warning rather than silently as "no proof".
- **"Claimed" comes off the listing's own `F` tag**, not from the cache's found logs. `F` is
authoritative once published and travels inside the event; the provisional timestamp-ordered
winner needs every verified log for the cache, so it belongs on the detail screen that
subscribes to them rather than on a feed card reading whatever replies happen to be in
memory.
3. Cache detail screen + NIP-22 logs (mostly wiring existing comment UI). This is where the
`dnf`/`note`/`maintenance` badge belongs: kind 1111 has no card of its own in `NoteCompose`,
it falls through to the generic text body, so badging a log type means touching the path every
NIP-22 comment in the app takes — worth doing once there is a cache thread to do it for. It is
also where the provisional first-to-find winner and the "multiple DNFs mean the cache is gone"
status heuristic go.
4. Write path: found logs, then DNF/note comments.
5. Verification: QR scan → ephemeral `7517` → embedded in the found log.
6. First-to-find / `F` lock-in, and the archived rendering rules.
7. Curation lists (37517) — independent of 1–6 and the easiest to defer.
Steps 1–2 are the "make Amethyst not blind to geocaches" milestone and are worth
shipping before anything in 3+ is designed.
## 6. Full integration design — screens, routes and entry points
Steps 3–7 above say *what* is missing. This section says what it looks like. Every Quartz
builder needed for the write path already exists and is tested (`GeocacheListingEvent.build`,
`GeocacheFoundLogEvent.build(message, cache, verification, images)`,
`GeocacheVerificationEvent.build(finderPubKey, cache)`, `GeocacheCurationListEvent.build`,
`GeocacheLogComment`), so **none of this needs protocol work** — it is navigation, composers and
one map. Tag coverage was re-checked against the spec's tables and is complete: 14/14 on 37516,
3/3 on 7516, 1/1 on 7517, 8/8 on 37517.
Wireframes: https://claude.ai/artifact/736F8KCTiaUkXCEpEii54C
### 6.1 Six screens, eight routes
| # | Screen | Route(s) | Template to copy |
|---|--------|----------|------------------|
| 1 | Geocaches hub (5 tabs) | `Route.Geocaches(initialTab: GeocacheTab? = null)` | `DiscoverScreen` (tab row + pager) |
| 2 | Cache detail | `Route.GeocacheDetail(kind, pubKeyHex, dTag)` | `calendars/detail/CalendarEventDetailScreen` |
| 3 | Log a find | `Route.LogGeocacheFind(kind, pubKeyHex, dTag)` | `composableFromBottomArgs`, NewPost chrome |
| 4 | Hide a cache | `Route.NewGeocache(draft)` / `Route.EditGeocache(address)` | `calendars/create/NewCalendarEventScreen` |
| 5 | Hunt detail | `Route.GeocacheHunt(kind, pubKeyHex, dTag)` | `SoftwareAppDetail` shape |
| 6 | Hunt composer | `Route.NewGeocacheHunt(draft)` / `Route.EditGeocacheHunt(address)` | follow-set picker |
The address-carrying routes use the same three-field `(kind, pubKeyHex, dTag)` + `Address`
secondary-constructor shape as `Route.EditCommunity` and `Route.AwardBadge`.
Hub tabs — one shared feed state, five views, so the map costs no extra subscription:
**Nearby** (distance-sorted) · **Map** · **Hunts** (37517) · **Finds** (my 7516s) · **Mine**
(caches I own). Needs a `GeocacheTab` enum next to `DiscoverTab` and a `dal/` filter per tab.
### 6.2 Deliberately sheets, not screens
- **Didn't find it** — one note + photo, publishing a kind 1111 with the `dnf` type. The same
sheet serves "add a note" and "needs maintenance"; only `GeocacheLogTypeTag` changes. This is
why no `logType` param gets bolted onto `Route.GenericCommentPost`.
- **Pin peek** on the map — raising a sheet instead of navigating keeps panning fluid.
- **Verification QR** (owner) — `QrCodeDrawer`, with the "only copy" warning.
### 6.3 Entry points
`NavBarItem.GEOCACHES` → `Route.Geocaches()` is the only new navigation surface, and it ships
**visible by default**. Adding the id to `NavBarCatalog` forces a drawer placement — it goes in
`DrawerFeedsItems` beside Polls, Products, Workouts and Calendars, and `DrawerSectionsTest` fails
if a catalog id appears in no section, so the repo already prevents a new destination from
silently vanishing. Hiding it is opt-out, via `DrawerSettingsScreen`.
The bottom bar is a separate list (`DefaultBottomBarItems` is five entries: Home, Messages,
Wallet, Browser, Notifications) and is left alone — geocaching earns a drawer row, not one of
those five slots.
Everything else is a destination change on something already tappable: a `GeocacheCard` in any feed, a "N caches
here" chip on the geohash screen, an `naddr` through `uriToRoute` (the treasures.to link path —
the interop entry that matters most), the notification feed (7516s already file under the cache
via `computeReplyTo`), the `kind:geocache` search alias, and a find count on a profile.
**The verification secret must never reach the global QR scanner.** `NIP19QrCodeScanner` maps
scans to routes; a cache's *published* `naddr` belongs there, its verification *private key* does
not. That scan happens only inside screen 3, via `SimpleQrCodeScanner`, which returns a raw
string.
### 6.4 What's genuinely new vs. reused
Reused wholesale: `GeocacheCard`/`GeocacheFoundLogCard`, `LocationPreviewMap`,
`LocationPickerMap`, `GeohashLocationPickerDialog`, `SimpleQrCodeScanner`, `QrCodeDrawer`,
`FeedTopNavFilterState`, `SaveableFeedContentState`, the NIP-22 thread UI, the upload pipeline,
`GeocacheVerificationValidator`.
Genuinely new Android UI: **a multi-marker map overlay with clustering**. Every map composable in
the repo places exactly one `Marker` (`LocationPreviewMap.kt:219`, `LocationPickerMap.kt:240`
both `removeAll { it is Marker }` first). That is the one piece with no precedent here.
### 6.5 Rules the screens must honour
- **Archived, or FTF locked in for someone else** → the find actions are replaced by a status
strip. NIP-CC asks for this explicitly.
- **`theme` / `map` on a 37517 are defaults, not mandates** — apply, then let the reader override.
- **`a` tag order on a hunt is meaningful** and `curatedGeocaches()` already preserves it.
- **Found logs publish to the cache's own `r` relays** (`logRelays()`) plus the user's write
relays.
- **A scanned verification key is attacker-controlled**: validate it is a 32-byte secp256k1
scalar, sign one 7517 on `Dispatchers.Default`, drop it. Never persisted, never logged, never
handed to a signer that outlives the call.
- **The find composer must not attach the finder's location**, and must say so on screen.
### 6.6 Build order — **built**
All six screens, eight routes and the sheets below now exist and compile. What landed:
| Piece | Where |
|---|---|
| 8 routes + `GeocacheTab` | `ui/navigation/routes/Routes.kt` |
| Drawer + catalog + bottom-bar category | `NavBarItem.kt`, `DrawerSections.kt` |
| Relay filter family (8 files) | `commons/.../relayClient/geocaches/` |
| Follow list, prefs, data source, 4 feed states | `AccountSettings`, `Account`, `LocalPreferences`, `RelaySubscriptionsCoordinator`, `AccountFeedContentStates` |
| 4 feed filters | `ui/.../geocaches/dal/` |
| Hub, 5 tabs, top bar, FAB, feed renderer | `ui/.../geocaches/` |
| Cache detail, log thread, owner actions, DNF sheet | `ui/.../geocaches/detail/` |
| Find composer + verification | `ui/.../geocaches/log/` |
| Cache composer | `ui/.../geocaches/create/` |
| Map tab with clustering | `ui/.../geocaches/map/` |
| Hunt detail + composer | `ui/.../geocaches/hunt/` |
| Owner revisions (archive, `F` lock-in) + 6 tests | `quartz/.../listing/GeocacheListingRevision.kt` |
Two things behave differently from the sketch, both deliberately:
- **Photos on a found log go through the standard upload pipeline**, not a geocaching-specific
one, so a find inherits Blossom/NIP-96 server choice and compression from the user's settings.
- **The Key Quest answer is appended to the log body** rather than given a tag. NIP-CC defines
`mission` on the listing and nothing for the response, so the only interoperable place for it
is the text every client already renders.
### 6.6a What the first pass got wrong
Two corrections worth keeping, because both were "done" in an earlier draft of this section and
neither was:
- **Five of the eight entry points did not exist.** `Route.GeocacheDetail` appeared in exactly
two places in the repo: its nav registration and the hub's own feed row. Everything else —
feed taps, notification taps, `nostr:naddr` deep links, QR scans, search hits — fell through
to the bare note view. All five run through one function, `routeForInner` in `RouteMaker.kt`,
where the calendar branch three lines above says so in as many words. Two branches fixed the
lot. The `naddr` case is the one that mattered: a treasures.to link is how most people will
first meet a cache in Amethyst.
- **Curation was write-once.** "Add to a hunt" only ever created a *new* hunt, so the moment a
player wanted a second cache on a route they had already built, the app had no answer. Now a
picker sheet lists the hunts the user owns and republishes the chosen one with the cache
appended (`GeocacheCurationRevision`, built on `update {}` so foreign tags survive).
Also added since: the "N caches here" chip on the geohash screen, and photo removal in the cache
composer, which could previously only add.
**Deliberately not built:** a find count on someone else's profile. It would read from
`LocalCache`, which for a stranger holds ~0 of their 7516s, so it would render "0 finds" for
almost everybody. Doing it honestly needs a per-profile subscription — a profile tab like Gallery
or Zaps — which is more work than the three items above combined, for a vanity number.
Still open, and honestly so: **none of it has been seen rendering**, and the cluster threshold
(50) is a guess that wants measuring on a mid-range device with a busy city on screen.
### 6.6b Original build order
1. Cache detail + log thread — gives every entry point somewhere to land.
2. Log a find, with verification — the first thing a player does, and it makes Amethyst the only
client that can *produce* a 7517 rather than only check one.
3. The hub minus the map — four feed filters and the drawer entry.
4. Hide a cache — the form plus the key ceremony; the largest single piece.
5. The map tab.
6. FTF lock-in and the archived rules.
7. Hunts — fully independent of 1–6.
### 6.7 Decisions (settled)
- **Nearby may turn on GPS**, behind an explicit "Use my location" the user taps. Feed cards keep
reading the cached fix and never subscribe; a distance-sorted list is worthless without a fix,
but scrolling a feed must still never start the GPS.
- **Geocaching gets a drawer row, on by default** — `DrawerFeedsItems`, hideable in
`DrawerSettingsScreen`. Not a default bottom-bar slot.
- **We write ROT13 hints.** The network is split 17/17 and the spec contradicts itself; reading
handles both, writing must pick. ROT13 is the reference client's choice and the one that fails
safe (a reader assuming plaintext sees noise, not the answer).
- **Cluster the map above roughly 50 visible markers** — a starting figure to measure on a real
device, not a guess to ship.
+1
View File
@@ -11,6 +11,7 @@ _Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0
## Queued
| Plan | Summary |
| ---- | ------- |
| [2026-09-15-qr-reader-overhaul.md](2026-09-15-qr-reader-overhaul.md) | QR reader rebuilt on CameraX + zxing-cpp in-app (replacing the Camera1 zxing-android-embedded activity) — continuous AF, zoom, torch, full-frame decode, explicit failure feedback, and gallery/clipboard import. |
| [2026-07-23-push-notification-redesign.md](2026-07-23-push-notification-redesign.md) | Per-kind tray notification redesign — accent colors, status-bar icons, MessagingStyle/BigPictureStyle/colorized zap cards, aggregation, Conversations/Bubbles; closes nutzap/onchain/repost/badge parity gaps. |
| [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. |
+230 -56
View File
@@ -1,71 +1,245 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
# =============================================================================
# R8 configuration for the release build.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Two things are balanced here.
#
# 1. Google Play measures how much of the shipped DEX R8 actually optimized
# and renamed, and warns (then restricts visibility/publishing) below 25%
# in either category. This file used to open with `-dontobfuscate` plus
# `-keepnames class ** { *; }`, and then kept all of `com.vitorpamplona.**`
# outright. That is the whole app and every library: `-dontobfuscate`
# turns renaming off globally, and `-keepnames` is shorthand for
# `-keep,allowshrinking`, which permits shrinking but neither renaming nor
# optimization. Hence 0% obfuscation / 13% optimization.
#
# 2. Anything the runtime reaches by NAME rather than by reference has to keep
# that name: JNI symbols, Jackson's reflective data binding, enum constants
# persisted into DataStore, class names written into a manifest meta-data
# value or into WorkManager's database.
#
# So every keep below is scoped to (2), and R8 gets everything else. mapping.txt
# is uploaded with each release, so stack traces stay retraceable.
#
# When adding a keep, say in a comment WHAT reads the name at runtime. A keep
# without that is usually a keep that is not needed.
# =============================================================================
# preserve the line number information for debugging stack traces.
-dontobfuscate
-keepattributes LocalVariableTable
-keepattributes LocalVariableTypeTable
-keepattributes *Annotation*
-keepattributes SourceFile
-keepattributes LineNumberTable
-keepattributes Signature
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes EnclosingMethod
-keepattributes MethodParameters
-keepparameternames
# -----------------------------------------------------------------------------
# Attributes
# -----------------------------------------------------------------------------
# These two are what make a crash report readable again. Keep them.
#
# There is no setting that gives readable stack traces *in the raw trace* once
# R8 is minifying, and that is worth being precise about, because it is the
# thing -dontobfuscate used to buy us:
#
# * R8 overwrites every class's SourceFile with the marker
# `r8-map-id-<hash>` whatever we do here. Verified by building it both
# ways: with `-renamesourcefileattribute SourceFile` all 24,440 classes
# report the literal "SourceFile"; without it they report the marker.
# There is no rule that restores the original per-class .kt name.
# * R8 renumbers lines even with LineNumberTable kept, because one
# obfuscated line now has to encode a whole INLINED frame stack. In this
# build, line 7 of one method carries three source frames:
# TextFieldCharSequence.getText():58 inlined into TextFieldState.getText()
# :146 inlined into ShortNotePostViewModel.onMessageChanged():1727. A raw
# line number is no longer a source line.
#
# That second point is a cost of OPTIMIZATION, not of renaming, and it is new
# here only because the old `-keepnames class ** { *; }` had optimization off
# program-wide — which is exactly what Play was complaining about.
#
# So the answer is retrace, not a keep rule. And retrace hands back more than
# the old raw traces did: it expands those inlined frames instead of collapsing
# them into one misleading line. See `scripts/retrace.sh` and RELEASE_OPS.md
# § 7. Two things make that painless, and both depend on this file:
#
# * `-renamesourcefileattribute` is deliberately NOT set, so the map-id
# marker survives. A pasted trace then names the exact mapping file it
# needs (the marker is the `pg_map_id` header of that mapping), so there is
# never any doubt about which release a report came from.
# * mapping.txt.gz ships as a GitHub Release asset for every build, so traces
# from F-Droid / Zapstore / Accrescent users are retraceable too — Play
# Console only auto-deobfuscates the AAB it was given.
-keepattributes SourceFile,LineNumberTable
# Generic signatures, plus the inner/enclosing-class links that travel with them.
#
# 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
# RuntimeVisible* annotation attributes. They stay spelled out here anyway: the
# duplicate is free (measured at 0 bytes) and it means a change to AGP's default
# file cannot quietly take Signature away from Jackson.
#
# Two attributes this line used to carry are gone, both measured on the arm64
# release DEX:
#
# * `*Annotation*` — over AGP's default its only contribution was the
# RuntimeInvisible* variants, which by definition cannot be read at runtime.
# Dropping it produced a byte-identical DEX (31,390,048 either way). Nothing
# we ship reads an annotation reflectively, and the libraries that do
# (kotlinx.serialization, appfunctions, AppSearch) match on RuntimeVisible*,
# which AGP already keeps.
# * `Exceptions` — @Throws metadata for 31 methods in shipped code, read by
# Java-interop compilers and by nothing at runtime. Worth 692 bytes.
#
# For the record, since it was wrong here for a while: this line used to credit
# jackson-module-kotlin with reading @kotlin.Metadata through it. That module no
# longer ships, and the Jackson mixins it also named were deleted along with the
# NWC Jackson path.
-keepattributes Signature,InnerClasses,EnclosingMethod
# LocalVariableTable, LocalVariableTypeTable, MethodParameters and
# -keepparameternames used to be kept here as well. They are debug metadata:
# nothing in the app reads them (jackson-module-kotlin takes parameter names
# from @kotlin.Metadata, not from MethodParameters), and they are pure DEX
# weight in a release build.
-keepdirectories libs
# Keep all names
-keepnames class ** { *; }
# -----------------------------------------------------------------------------
# JNI — names that live in a .so, not in the DEX
# -----------------------------------------------------------------------------
# proguard-android-optimize.txt already contributes
# -keepclasseswithmembernames class * { native <methods>; }
# which pins every class that DECLARES a native method (ArtiNative,
# secp256k1's loader, …) together with those methods' names, because the
# exported symbol is Java_<class>_<method>. What that does NOT cover is the
# traffic in the other direction: Java/Kotlin the native side looks up itself.
# Keep All enums
-keep enum ** { *; }
# libarti_android.so calls back into this interface by name —
# tools/arti-build/src/lib.rs does GetMethodID("onLogLine") on it. Renaming the
# method silently kills all Tor log output.
-keep class com.vitorpamplona.amethyst.ui.tor.ArtiLogCallback { *; }
# preserve access to native classses
# secp256k1's JNI layer resolves these from native code.
-keep class fr.acinq.secp256k1.** { *; }
# JNA For Libsodium
-keep class com.goterl.lazysodium.** { *; }
# zxing-cpp's JNI boundary. The native library exports name-mangled symbols
# (Java_zxingcpp_BarcodeReader_readYBuffer), so R8 renaming the class or its
# external methods breaks the lookup at runtime with no build error and no
# warning -- the QR scanner simply fails to start in release builds.
#
# This arrived automatically as the io.github.zxing-cpp:android AAR's consumer
# rule. We build that library ourselves now (tools/zxing-cpp-build) and vendor
# its Kotlin half, so the rule is ours to carry.
-keep class zxingcpp.** { *; }
# libscrypt
-keep class com.lambdaworks.codec.** { *; }
-keep class com.lambdaworks.crypto.** { *; }
-keep class com.lambdaworks.jni.** { *; }
# Nothing keeps libscrypt, NetCipher/tor-android, LazySodium or JNA any more:
# quartz replaced libsodium with a pure-Kotlin implementation (LibSodiumInstance)
# and Tor now runs through arti's own JNI layer. Their rules used to live here and
# matched zero classes in the release build — none of those artifacts appear in
# mapping.txt, usage.txt or seeds.txt, i.e. they are not on the classpath at all.
# If one ever comes back, so must its rule: JNA in particular maps types onto the
# C ABI by reflecting over their fields and method signatures at runtime.
-keep class info.guardianproject.** { *; }
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
-keep class com.sun.jna.ToNativeConverter { *; }
-keep class com.sun.jna.NativeMapped { *; }
-keep class com.sun.jna.CallbackReference { *; }
-keep class com.sun.jna.ptr.IntByReference { *; }
-keep class com.sun.jna.NativeLong { *; }
-keep class com.sun.jna.Structure { *; }
-keep class com.sun.jna.Structure$* { *; }
-keep class com.sun.jna.Native$ffi_callback { *; }
-keep class * implements com.sun.jna.Structure$* { *; }
-keep class * implements com.sun.jna.Native$* { *; }
-keep class com.sun.jna.Native {
private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object);
private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object);
private static java.lang.Class nativeType(java.lang.Class);
private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object);
private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method);
# -----------------------------------------------------------------------------
# Enum constant names
# -----------------------------------------------------------------------------
# An enum constant's NAME is persisted data in this app. The preference stores
# write `enum.name` into DataStore and read it back with `Type.valueOf(string)`
# (see model/preferences/UISharedPreferences.kt, TorSharedPreferences.kt,
# NamecoinSharedPreferences.kt), and Jackson serialises enums by name too.
# Enum.valueOf resolves that string against the static FIELD name, so renaming
# the constants would reset every user's theme/font/Tor/connectivity setting on
# the first launch after an update.
#
# Deliberately blanket rather than a list of the enums that happen to be
# persisted today: the failure mode is silent, release-only, and one new
# `preferences[KEY] = value.name` line away. Only the field names are pinned —
# the enum classes themselves are still renamed and their methods still
# optimized.
-keepclassmembers enum * {
<fields>;
public static **[] values();
public static ** valueOf(java.lang.String);
}
# JSON parsing
-keep class com.vitorpamplona.quartz.** { *; }
-keep class com.vitorpamplona.amethyst.** { *; }
# -----------------------------------------------------------------------------
# Jackson — reflective data binding only
# -----------------------------------------------------------------------------
# Most of Quartz's wire format does NOT need a keep. 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, which read and write
# property names as string literals. Renaming their fields changes nothing on
# the wire.
#
# What remains is the code Jackson data-binds REFLECTIVELY — `treeToValue(...)`
# and `readValue<T>()` with no custom deserializer. There the JSON property
# names come from the Kotlin constructor parameter names, so a renamed field is
# a changed wire format.
# Room generates *_Impl subclasses instantiated reflectively via no-arg constructor.
# -keepnames preserves the name but R8 still strips the unused <init>().
-keep class * extends androidx.room.RoomDatabase {
<init>();
}
# NIP-47 and CLINK used to need a package keep each, because Jackson bound their
# ~106 concrete classes reflectively. Both are gone: OptimizedJsonMapper routes
# those types at the hand-written kotlinx serializers, which name every field as a
# string literal. Nothing to keep, nothing to verify.
# The on-disk stores (scheduled posts, pending PoW jobs, resource usage) used to
# need a keep each, because Jackson derived their JSON keys from the Kotlin
# constructor parameter names. They are @Serializable now: kotlinx bakes every key
# in as a string literal, so the field names can be renamed freely. The formats are
# pinned by ScheduledPostFileFormatTest and PowAndUsageFileFormatTest instead,
# which assert the bytes against what the Jackson build wrote.
# -----------------------------------------------------------------------------
# Names referenced from outside the DEX
# -----------------------------------------------------------------------------
# AGP generates keeps from the merged manifest's component `android:name`
# attributes, but NOT from <meta-data android:value>. The Cast framework reads
# this one out of the manifest and Class.forName()s it.
-keep class com.vitorpamplona.amethyst.service.cast.chromecast.AmethystCastOptionsProvider { *; }
# NOT a rule for WorkManager. It stores the worker's class name in its own
# database at enqueue time and instantiates it by name on a later process start
# — including after an app update that reshuffled the mapping — so the name does
# have to survive. But androidx.work already ships exactly that in its own
# consumer rules (`-keepnames class * extends androidx.work.ListenableWorker`
# plus a keepclassmembers for the public constructors), so a rule here was pure
# duplication. The three workers stay listed in the reflection contract, which
# now verifies the LIBRARY's rule keeps doing the job.
# androidx.appfunctions: the KSP-generated invokers and the app_functions.xml
# the system reads are keyed off these declarations. One class plus its
# 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 { *; }
-keep class com.vitorpamplona.amethyst.ui.navigation.routes.GeocacheTab { *; }
@@ -0,0 +1,16 @@
blur 3 3
blur-heavy 0 3
clean 3 3
far-1.5px 0 3
far-2px 2 3
far-3px 3 3
glare 3 3
inverted 3 3
low-contrast 3 3
moire 3 3
noise 2 3
perspective 0 3
tilt15 2 3
tilt30 2 3
tilt45 2 3
very-low-contrast 0 3
1 blur 3 3
2 blur-heavy 0 3
3 clean 3 3
4 far-1.5px 0 3
5 far-2px 2 3
6 far-3px 3 3
7 glare 3 3
8 inverted 3 3
9 low-contrast 3 3
10 moire 3 3
11 noise 2 3
12 perspective 0 3
13 tilt15 2 3
14 tilt30 2 3
15 tilt45 2 3
16 very-low-contrast 0 3
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,48 @@
clean-npub.png clean nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
blur-npub.png blur nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
blur-heavy-npub.png blur-heavy nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
tilt15-npub.png tilt15 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
tilt30-npub.png tilt30 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
tilt45-npub.png tilt45 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
perspective-npub.png perspective nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
low-contrast-npub.png low-contrast nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
very-low-contrast-npub.png very-low-contrast nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
inverted-npub.png inverted nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
glare-npub.png glare nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
noise-npub.png noise nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
moire-npub.png moire nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
far-3px-npub.png far-3px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
far-2px-npub.png far-2px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
far-1.5px-npub.png far-1.5px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
clean-nprofile.png clean nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
blur-nprofile.png blur nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
blur-heavy-nprofile.png blur-heavy nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
tilt15-nprofile.png tilt15 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
tilt30-nprofile.png tilt30 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
tilt45-nprofile.png tilt45 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
perspective-nprofile.png perspective nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
low-contrast-nprofile.png low-contrast nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
very-low-contrast-nprofile.png very-low-contrast nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
inverted-nprofile.png inverted nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
glare-nprofile.png glare nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
noise-nprofile.png noise nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
moire-nprofile.png moire nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
far-3px-nprofile.png far-3px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
far-2px-nprofile.png far-2px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
far-1.5px-nprofile.png far-1.5px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
clean-nevent.png clean nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
blur-nevent.png blur nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
blur-heavy-nevent.png blur-heavy nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
tilt15-nevent.png tilt15 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
tilt30-nevent.png tilt30 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
tilt45-nevent.png tilt45 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
perspective-nevent.png perspective nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
low-contrast-nevent.png low-contrast nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
very-low-contrast-nevent.png very-low-contrast nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
inverted-nevent.png inverted nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
glare-nevent.png glare nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
noise-nevent.png noise nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
moire-nevent.png moire nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
far-3px-nevent.png far-3px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
far-2px-nevent.png far-2px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
far-1.5px-nevent.png far-1.5px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
1 clean-npub.png clean nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
2 blur-npub.png blur nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
3 blur-heavy-npub.png blur-heavy nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
4 tilt15-npub.png tilt15 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
5 tilt30-npub.png tilt30 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
6 tilt45-npub.png tilt45 nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
7 perspective-npub.png perspective nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
8 low-contrast-npub.png low-contrast nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
9 very-low-contrast-npub.png very-low-contrast nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
10 inverted-npub.png inverted nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
11 glare-npub.png glare nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
12 noise-npub.png noise nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
13 moire-npub.png moire nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
14 far-3px-npub.png far-3px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
15 far-2px-npub.png far-2px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
16 far-1.5px-npub.png far-1.5px nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq
17 clean-nprofile.png clean nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
18 blur-nprofile.png blur nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
19 blur-heavy-nprofile.png blur-heavy nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
20 tilt15-nprofile.png tilt15 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
21 tilt30-nprofile.png tilt30 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
22 tilt45-nprofile.png tilt45 nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
23 perspective-nprofile.png perspective nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
24 low-contrast-nprofile.png low-contrast nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
25 very-low-contrast-nprofile.png very-low-contrast nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
26 inverted-nprofile.png inverted nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
27 glare-nprofile.png glare nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
28 noise-nprofile.png noise nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
29 moire-nprofile.png moire nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
30 far-3px-nprofile.png far-3px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
31 far-2px-nprofile.png far-2px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
32 far-1.5px-nprofile.png far-1.5px nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq
33 clean-nevent.png clean nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
34 blur-nevent.png blur nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
35 blur-heavy-nevent.png blur-heavy nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
36 tilt15-nevent.png tilt15 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
37 tilt30-nevent.png tilt30 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
38 tilt45-nevent.png tilt45 nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
39 perspective-nevent.png perspective nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
40 low-contrast-nevent.png low-contrast nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
41 very-low-contrast-nevent.png very-low-contrast nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
42 inverted-nevent.png inverted nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
43 glare-nevent.png glare nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
44 noise-nevent.png noise nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
45 moire-nevent.png moire nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
46 far-3px-nevent.png far-3px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
47 far-2px-nevent.png far-2px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
48 far-1.5px-nevent.png far-1.5px nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -25,9 +25,9 @@ import android.graphics.Color
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.service.uploads.ImageDownloader
@@ -21,12 +21,12 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
@@ -21,13 +21,12 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -163,7 +162,7 @@ class ThreadDualAxisChartAssemblerTest {
fun threadOrderTest() =
runBlocking {
val eventArray =
JacksonMapper.mapper.readValue<List<Event>>(db) + Event.fromJson(header)
JacksonMapper.mapper.readValue<List<Event>>(db, JacksonMapper.eventListTypeInstance) + Event.fromJson(header)
var counter = 0
eventArray.forEach {
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
/**
* Runs zxing-cpp over the committed [QrCorpus] images and asserts it reads at least as many as
* ZXing-Java did, category by category.
*
* This is the gate the plan's phase 0 asks for: the justification for replacing the decoder is
* supposed to be a measurement, not an argument. The baseline it compares against was produced on
* the JVM by `QrCorpusBaselineTest` from the same images.
*
* Regenerate both with:
* ```
* ./gradlew :amethyst:testFdroidDebugUnitTest --tests '*QrCorpusBaselineTest*' \\
* -Pamethyst.qr.corpus.export=true
* ```
*/
@RunWith(AndroidJUnit4::class)
class QrDecodeCorpusTest {
private val assets = InstrumentationRegistry.getInstrumentation().context.assets
private val decoder = ZxingCppBarcodeDecoder()
@Test
fun readsEveryCleanCode() {
fixtures().filter { it.category == "clean" }.forEach {
assertTrue("clean fixture ${it.file} must decode", decode(it))
}
}
@Test
fun beatsTheOldDecoderInEveryCategory() {
val baseline = baseline()
assertTrue("baseline.tsv missing - regenerate the corpus", baseline.isNotEmpty())
val results = fixtures().groupBy { it.category }.mapValues { (_, list) -> list.count { decode(it) } }
val regressions = mutableListOf<String>()
val report =
buildString {
appendLine()
appendLine("category zxing-cpp ZXing-Java")
baseline.keys.sorted().forEach { category ->
val old = baseline.getValue(category)
val new = results[category] ?: 0
appendLine(" %-20s %d/%d %d/%d".format(category, new, old.total, old.passed, old.total))
if (new < old.passed) regressions += "$category: $new < ${old.passed}"
}
}
println(report)
if (regressions.isNotEmpty()) {
fail("zxing-cpp read fewer codes than ZXing-Java in: ${regressions.joinToString("; ")}$report")
}
}
private data class Fixture(
val file: String,
val category: String,
val expected: String,
)
private data class Baseline(
val passed: Int,
val total: Int,
)
private fun decode(fixture: Fixture): Boolean {
val bitmap =
assets.open("$ASSET_DIR/${fixture.file}").use {
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 })
} ?: return false
return try {
decoder.decode(bitmap, DecodeEffort.Thorough).any { it.text == fixture.expected }
} finally {
bitmap.recycle()
}
}
private fun fixtures(): List<Fixture> =
assets.open("$ASSET_DIR/expected.tsv").bufferedReader().useLines { lines ->
lines
.filter { it.isNotBlank() }
.map { line ->
val (file, category, expected) = line.split('\t', limit = 3)
Fixture(file, category, expected)
}.toList()
}
private fun baseline(): Map<String, Baseline> =
assets.open("$ASSET_DIR/baseline.tsv").bufferedReader().useLines { lines ->
lines.filter { it.isNotBlank() }.associate { line ->
val (category, passed, total) = line.split('\t', limit = 3)
category to Baseline(passed.toInt(), total.toInt())
}
}
companion object {
private const val ASSET_DIR = "qr"
}
}
+27 -6
View File
@@ -188,6 +188,14 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Long-press the launcher icon to scan a QR code. The shortcut fires a VIEW intent
at nostr:scanqr, which the nostr-scheme filter below already catches, so it needs
no hardcoded targetPackage - debug and benchmark builds carry an applicationId
suffix that would break one. uriToRoute turns it into the scanner. -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -377,6 +385,25 @@
</intent-filter>
</activity-alias>
<!-- "Scan QR code" share target: an image shared here is decoded rather than posted, which
is how most QR codes actually arrive - a screenshot or a photo someone sent you. SEND
only: a QR scan reads one code, so there is nothing sensible to do with a multi-select.
The android:name simple class ("ScanQrCodeAlias") is matched at runtime by
ShareIntentRouting.SCAN_QR_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ScanQrCodeAlias"
android:exported="true"
android:label="@string/share_target_scan_qr"
android:icon="@drawable/ic_qrcode"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_scan_qr">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity-alias>
<!-- "New Short" share target: a video shared here always becomes a NIP-71 kind-22 short so
it lands in the Shorts feed, whatever its orientation. Video-only — a short is a video.
The android:name simple class ("ShareAsShortVideoAlias") is matched at runtime by
@@ -470,12 +497,6 @@
</intent-filter>
</activity-alias>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation"
tools:ignore="DiscouragedApi" />
<activity
android:name=".ui.screen.loggedIn.nests.room.activity.NestActivity"
android:autoRemoveFromRecents="true"
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.commons.model.cache.FileSystemNip95BlobStore
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.LocalCacheHost
import com.vitorpamplona.amethyst.commons.model.cache.Nip95BlobStore
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo
import kotlinx.coroutines.CoroutineScope
/**
* Binds the shared [LocalCache] in `commons` to this app's shell.
*
* Every member reads through to [modules] on each call rather than capturing a value, because
* three of the four are lazy and are built long after the cache singleton exists.
*/
class AmethystLocalCacheHost(
private val modules: AppModules,
/**
* Passed in rather than read here: the top-level `isDebug` and this override share a name,
* so inside the class body the name would resolve to the override itself.
*/
override val isDebug: Boolean,
) : LocalCacheHost {
override val scope: CoroutineScope get() = modules.applicationIOScope
// by lazy, not a getter: modules.nip95cache is itself lazy and creating the directory
// is the point of touching it, so this must not happen once per NIP-95 event.
override val nip95Blobs: Nip95BlobStore by lazy { FileSystemNip95BlobStore(modules.nip95cache.absolutePath) }
override val relayStats: RelayStats get() = modules.relayStats
override fun relaySelfPubKey(relay: NormalizedRelayUrl): HexKey? = modules.nip11Cache.getFromCache(relay).self
// The zap path's LNURL cache is a quartz-side singleton the outbound-zap resolver
// fills; it is jvmAndroid-only, which is why the cache reads it through here.
override fun lnurlEndpoint(lnurlpUrl: String): LnurlEndpointInfo? = LnurlEndpointCache.get(lnurlpUrl)
override fun assertNotMainThread() = checkNotInMainThread()
}
@@ -28,6 +28,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
@@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.connectedApps.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
@@ -702,6 +702,14 @@ class AppModules(
// Caches all events in Memory
val cache: LocalCache = LocalCache
// The cache lives in `commons` and knows nothing about this app. Hand it the pieces it
// cannot supply for itself — a scope, the NIP-95 blob directory, the relay identity and
// stats sinks, the debug flag and the main-thread assertion — before anything consumes an
// event, which is why this sits next to the cache rather than in a later init block.
init {
cache.appHost = AmethystLocalCacheHost(this, isDebug)
}
// NIP-BC onchain zap verification backend. Wired up once at app init so
// LocalCache.consume(OnchainZapEvent) can sum the on-chain output values
// that pay the recipient's derived Taproot address. Wrapped in a caching
@@ -25,7 +25,7 @@ import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Debug
import androidx.core.content.getSystemService
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls
import com.vitorpamplona.quartz.utils.Log
@@ -136,6 +136,7 @@ private object PrefKeys {
const val DEFAULT_HIGHLIGHTS_FOLLOW_LIST = "defaultHighlightsFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_GEOCACHES_FOLLOW_LIST = "defaultGeocachesFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
const val DEFAULT_PUBLIC_CHATS_FOLLOW_LIST = "defaultPublicChatsFollowList"
const val DEFAULT_LIVE_STREAMS_FOLLOW_LIST = "defaultLiveStreamsFollowList"
@@ -537,6 +538,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHighlightsFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_GEOCACHES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGeocachesFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
putString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPublicChatsFollowList.value))
putString(PrefKeys.DEFAULT_LIVE_STREAMS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLiveStreamsFollowList.value))
@@ -984,6 +986,7 @@ object LocalPreferences {
defaultHighlightsFollowList = MutableStateFlow(followListPrefs.highlights),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultGeocachesFollowList = MutableStateFlow(followListPrefs.geocaches),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
defaultPublicChatsFollowList = MutableStateFlow(followListPrefs.publicChats),
defaultLiveStreamsFollowList = MutableStateFlow(followListPrefs.liveStreams),
@@ -1099,6 +1102,7 @@ object LocalPreferences {
val highlights: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val geocaches: TopFilter,
val shorts: TopFilter,
val publicChats: TopFilter,
val liveStreams: TopFilter,
@@ -1157,6 +1161,7 @@ object LocalPreferences {
highlights = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
geocaches = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GEOCACHES_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
publicChats = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, null), TopFilter.Global),
liveStreams = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_LIVE_STREAMS_FOLLOW_LIST, null), TopFilter.Global),
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_allow_always
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_fewer_options
@@ -94,7 +95,6 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_deny
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_remember
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_signing_as
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
@@ -28,7 +28,7 @@ import android.widget.Toast
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
@@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.favorites
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -30,7 +30,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.napplethost.NappletBlobCache
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
@@ -1070,6 +1071,9 @@ class Account(
val liveProductsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultProductsFollowList)
val liveProductsFollowListsPerRelay = OutboxLoaderState(liveProductsFollowLists, cache, scope).flow
val liveGeocachesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultGeocachesFollowList)
val liveGeocachesFollowListsPerRelay = OutboxLoaderState(liveGeocachesFollowLists, cache, scope).flow
val liveShortsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultShortsFollowList)
val liveShortsFollowListsPerRelay = OutboxLoaderState(liveShortsFollowLists, cache, scope).flow
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
@@ -170,6 +170,7 @@ class AccountSettings(
val defaultHighlightsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultGeocachesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPublicChatsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultLiveStreamsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -724,6 +725,7 @@ class AccountSettings(
defaultHighlightsFollowList to TopFilter.Global,
defaultCalendarsFollowList to TopFilter.Global,
defaultProductsFollowList to TopFilter.AroundMe,
defaultGeocachesFollowList to TopFilter.AroundMe,
defaultShortsFollowList to TopFilter.Global,
defaultPublicChatsFollowList to TopFilter.Global,
defaultLiveStreamsFollowList to TopFilter.Global,
@@ -923,6 +925,17 @@ class AccountSettings(
}
}
fun changeDefaultGeocachesFollowList(name: FeedDefinition) {
changeDefaultGeocachesFollowList(name.code)
}
fun changeDefaultGeocachesFollowList(name: TopFilter) {
if (defaultGeocachesFollowList.value != name) {
defaultGeocachesFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultShortsFollowList(name: FeedDefinition) {
changeDefaultShortsFollowList(name.code)
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
@@ -20,7 +20,9 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
import com.vitorpamplona.amethyst.commons.model.nip90DVMs.DvmHeartbeatRegistry
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -52,6 +53,19 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
/**
* The pubkeys an `a`-tagging event addresses, read straight from the coordinates
* (`kind:pubkey:dTag`) rather than from whatever the local cache happens to hold.
*
* This is what lets [EventBroadcaster] route to an addressed author's inbox relays when the
* addressable itself was never cached on this device - a NIP-52 RSVP being the motivating case,
* since its `a` tag is the only thing tying it to the appointment's host.
*
* Unparseable coordinates are dropped; the result is deduplicated because an event may address
* several addressables by the same author (a calendar listing its own appointments).
*/
fun addressedAuthors(event: AddressHintProvider): Set<HexKey> = event.linkedAddressIds().mapNotNullTo(mutableSetOf()) { Address.parse(it)?.pubKeyHex }
/**
* The sign-and-publish choke point for an [Account]: computes the relay set an
* event should be broadcast to (NIP-65 outbox model, relay hints, channel home
@@ -232,6 +246,16 @@ class EventBroadcaster(
event.addressHints().forEach {
relayList.add(it.relay)
}
// An `a` coordinate names its own author, so the addressed user's inbox is reachable
// straight from the tag. Everything in the loop below is nested inside a cache
// lookup, so without this an event aimed at an addressable this device never cached
// - an RSVP to a calendar appointment that arrived as a bare reference, say - went
// only to the sender's own outbox and never to the author it was answering.
addressedAuthors(event).forEach { authorPubKey ->
relayList.addAll(computeRelayListForLinkedUser(authorPubKey))
}
event.linkedAddressIds().forEach { addressId ->
account.cache.getAddressableNoteIfExists(addressId)?.let { linkedNote ->
val linkedNoteAuthor = linkedNote.author
@@ -20,7 +20,8 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache.observeEvents
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
@@ -20,7 +20,8 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache.observeEvents
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
class ParticipantListBuilder {
@@ -27,11 +27,11 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.marmot.AndroidIngestDedupStore
import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.model.bolt12Offers
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Log
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.model.buzz
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.buzz.ChannelClassification
import com.vitorpamplona.amethyst.commons.model.buzz.MembershipNotice
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.buzz.MembershipNotificationKinds
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
@@ -24,8 +24,8 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
@@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.model.edits
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.model.localRelays
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.model.marmot
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.model.nip01UserMetadata
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.model.nip02FollowLists
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.model.nip02FollowLists
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.UsingRelayUnwrapper
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope

Some files were not shown because too many files have changed in this diff Show More