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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
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
|
||||
|
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||