Merge remote-tracking branch 'origin/main' into claude/relaxed-gates-m0h15t

This commit is contained in:
Claude
2026-09-21 12:20:48 +00:00
220 changed files with 10128 additions and 4382 deletions
+13 -1
View File
@@ -224,9 +224,21 @@ version. `quartz/` is protocol-only — no composables.
# Build Quartz for all targets
./gradlew :quartz:build
# Run tests
# Run the JVM tests of every module, KMP ones included. KMP modules register no
# `test` task of their own; the root build aliases it onto their jvmTest.
./gradlew test
# A single module. For a KMP module name jvmTest directly:
./gradlew :quartz:jvmTest --tests "com.vitorpamplona.quartz.nip52Calendar.*"
# NOT covered by `test`: each KMP module's OTHER targets, notably
# :quartz:testAndroidHostTest (~3.9k tests, its own androidHostTest source set).
# Nothing runs it today - not `test`, not pre-push, not CI - and it has 4 known
# failures on main (NostrServerTest x3, LiveNegentropyIndexStoreTest x1), which
# is why the alias maps to jvmTest rather than allTests. Run it explicitly when
# touching relay-server or store code:
./gradlew :quartz:testAndroidHostTest
# Format code
./gradlew spotlessApply
```
+4
View File
@@ -1,4 +1,8 @@
{
"env": {
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8"
},
"hooks": {
"PreToolUse": [
{
@@ -11,230 +11,178 @@ Proguard configuration for optimizing and obfuscating Android APK while preservi
## Amethyst Proguard Configuration
**File:** `amethyst/proguard-rules.pro`
**Files:**
### Keep Kotlin Metadata
- `amethyst/proguard-rules.pro` — the app's rules. Read it before adding
anything: it is commented rule by rule.
- `quartz/consumer-rules.pro` — merged into the R8 configuration of **every**
app that depends on Quartz, this one included (wired via
`optimization.consumerKeepRules` in `quartz/build.gradle.kts`). A rule added
here silently applies to somebody else's whole program.
- The AGP default `proguard-android-optimize.txt`, which already contributes the
Android-wide basics (Parcelable CREATOR fields, `native <methods>`, the enum
`values()`/`valueOf()` pair, …). Don't restate its rules.
- `commons/`, `commonsUI/` and the other library modules deliberately have **no**
rules files. They are not minified and they wire no consumer rules, so a file
there would be dead configuration.
### The policy: keep only what is reached BY NAME
Google Play measures how much of a shipped app's DEX R8 actually optimized and
renamed, and warns — then restricts store visibility and publishing — below 25%
in either category. Amethyst has been on the wrong side of that line: a
`-dontobfuscate` plus `-keepnames class ** { *; }` at the top of both
`proguard-rules.pro` and `quartz/consumer-rules.pro`, and outright
`-keep class com.vitorpamplona.** { *; }` for the app's own code, produced 0%
obfuscation and 13% optimization. (`-keepnames` is not the mild rule it looks
like: it expands to `-keep,allowshrinking`, which permits shrinking but neither
renaming nor optimization — applied to `**` it disables R8 for the entire
program, libraries included.)
So the standing rule is: **a keep needs a named runtime mechanism that reads the
name.** In this app those are, exhaustively:
| Mechanism | Example | Rule shape |
|---|---|---|
| JNI symbol `Java_<class>_<method>` | `ArtiNative`, secp256k1 | covered by the default `native <methods>` rule |
| Native code calling *back* by name | `ArtiLogCallback.onLogLine`, looked up with `GetMethodID` in `tools/arti-build/src/lib.rs` | `-keep class …ArtiLogCallback { *; }` |
| Generated code reflected over by a library | the AppFunctions bridge (`appfunctions.**`, play flavor only) | `-keep class <pkg>.** { *; }` |
| Enum constant persisted as a string | `UISharedPreferences` writes `enum.name`, reads `Type.valueOf(s)` | `-keepclassmembers enum * { <fields>; … }` |
| Class name in a manifest `<meta-data android:value>` | `AmethystCastOptionsProvider` | explicit `-keep` — AGP generates keeps from component `android:name`, **not** from meta-data |
| Class name in WorkManager's database | the three `CoroutineWorker`s | `-keep class * extends androidx.work.ListenableWorker { <init>(...); }` |
What does **not** need a keep, and where the temptation usually comes from:
- **Quartz events and tags.** `Event`, `Filter`, `Message`, `Command`, `Rumor`,
`EventTemplate`, `TagArray`, the NIP-46 Bunker messages and the NIP-55 intent
results all go through hand-written `StdSerializer`/`StdDeserializer` pairs
registered on `JacksonMapper` / `JsonMapperNip55`. Those read and write
property names as string literals, and `EventFactory` dispatches on kind with
a `when`, not by reflection. Renaming their fields changes nothing on the wire.
- **`@Serializable` (kotlinx) classes**, including the type-safe navigation
routes. The compiler plugin generates a descriptor holding the serial name and
every property name as **compile-time string literals**, so obfuscation cannot
reach them. kotlinx-serialization ships its own consumer rules for the
`$$serializer`/`Companion` plumbing.
- **Compose, Coil, OkHttp, Media3, Firebase, kotlin-reflect.** Every one of them
ships consumer rules inside its own artifact. Check
`build/outputs/mapping/<variant>/configuration.txt` — the fully merged
configuration — before writing a rule for a third-party library.
- **Manifest-declared components** (activities, services, receivers, providers)
and classes named in layout/`res/xml`. AGP generates those keeps itself, which
is why `Intent().setClassName(ctx, "…NappletBrowserService")` is safe.
### Attributes
```proguard
# Kotlin metadata is required for reflection
-keep class kotlin.Metadata { *; }
-keep class kotlin.** { *; }
-dontwarn kotlin.**
# Kotlin serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class com.vitorpamplona.**$$serializer { *; }
-keepclassmembers class com.vitorpamplona.** {
*** Companion;
}
-keepclasseswithmembers class com.vitorpamplona.** {
kotlinx.serialization.KSerializer serializer(...);
}
```
### Keep Nostr Event Classes
```proguard
# Nostr events are serialized/deserialized
-keep class com.vitorpamplona.quartz.events.** { *; }
-keep class com.vitorpamplona.quartz.encoders.** { *; }
# Keep event builders
-keep class com.vitorpamplona.quartz.builders.** { *; }
# Keep tag classes
-keep class com.vitorpamplona.quartz.nip01Core.tags.** { *; }
```
### Keep Data Classes
```proguard
# Data classes used in ViewModels and serialization
-keep @kotlinx.serialization.Serializable class * { *; }
# Keep all data classes
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
```
### Keep Compose Classes
```proguard
# Jetpack Compose
-keep class androidx.compose.** { *; }
-dontwarn androidx.compose.**
# Compose runtime
-keep class androidx.compose.runtime.** { *; }
# Compose UI
-keep class androidx.compose.ui.** { *; }
# Material3
-keep class androidx.compose.material3.** { *; }
# Navigation Compose - Keep serializable routes
-keep class * implements java.io.Serializable { *; }
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
!static !transient <fields>;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
```
### Keep OkHttp/Retrofit
```proguard
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
# OkHttp WebSockets (for Nostr relays)
-keep class okhttp3.internal.ws.** { *; }
# Retrofit (if used)
-keepattributes Signature
-keepattributes Exceptions
-keep class retrofit2.** { *; }
```
### Keep Jackson (JSON)
```proguard
# Jackson JSON library
-keep class com.fasterxml.jackson.** { *; }
-keep class org.codehaus.** { *; }
-keepclassmembers class * {
@com.fasterxml.jackson.annotation.* <methods>;
}
# Jackson polymorphic types
-keepattributes RuntimeVisibleAnnotations
-keep @com.fasterxml.jackson.annotation.JsonTypeInfo class *
```
### Keep Secp256k1 (Crypto)
```proguard
# Secp256k1 native library
-keep class fr.acinq.secp256k1.** { *; }
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
```
### Keep Tor
```proguard
# Tor library
-keep class com.msopentech.thali.toronionproxy.** { *; }
-dontwarn com.msopentech.thali.toronionproxy.**
```
### Keep ExoPlayer (Media)
```proguard
# ExoPlayer (Media3)
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
-keep class com.google.android.exoplayer2.** { *; }
-dontwarn com.google.android.exoplayer2.**
```
### Keep Coil (Image Loading)
```proguard
# Coil image loading
-keep class coil.** { *; }
-keep class coil3.** { *; }
-dontwarn coil.**
-dontwarn coil3.**
```
### Keep ViewModels
```proguard
# ViewModel classes
-keep class * extends androidx.lifecycle.ViewModel {
<init>();
}
# ViewModel factories
-keep class * extends androidx.lifecycle.ViewModelProvider$Factory {
<init>(...);
}
# Keep ViewModel constructors for reflection
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
<init>(...);
}
```
### Keep Parcelable
```proguard
# Parcelable
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
-keepclassmembers class * implements android.os.Parcelable {
public <fields>;
private <fields>;
}
```
### Keep Enums
```proguard
# Enums
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
```
### Remove Logging (Production)
```proguard
# Remove debug logging in release builds
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
# Keep error/warning logs
-assumenosideeffects class android.util.Log {
public static *** e(...) return false;
public static *** w(...) return false;
}
```
### Keep Crashlytics/Firebase
```proguard
# Firebase Crashlytics
# What makes a crash report retraceable.
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception
# Firebase
-keep class com.google.firebase.** { *; }
-dontwarn com.google.firebase.**
# jackson-module-kotlin reads @kotlin.Metadata; R8 requires InnerClasses and
# EnclosingMethod alongside Signature.
-keepattributes *Annotation*,Signature,Exceptions,InnerClasses,EnclosingMethod
```
`LocalVariableTable`, `LocalVariableTypeTable`, `MethodParameters` and
`-keepparameternames` are debug metadata that nothing in the app reads —
jackson-module-kotlin takes parameter names from `@kotlin.Metadata`, not from
`MethodParameters`. They were removed; don't add them back.
`-renamesourcefileattribute` is deliberately **not** set, and the reason is not
the usual one.
Once R8 is minifying it rewrites every class's `SourceFile` to the marker
`r8-map-id-<hash>` on its own — there is no rule that restores the original
per-class `.kt` name. Verified by building it both ways: with
`-renamesourcefileattribute SourceFile`, all 24,440 classes report the literal
`"SourceFile"`; without it, they report the marker. So the rule cannot buy
readability, it can only *destroy* the marker — and that marker is the
`pg_map_id` header of the mapping that produced the build, which is what lets a
pasted stack trace name the exact mapping file it needs.
Two related facts worth knowing before someone tries to "fix" stack traces with
keep rules:
- **Raw line numbers are no longer source line numbers.** R8 renumbers them so
that one obfuscated line can encode a whole inlined frame stack. This is a
cost of *optimization*, not of renaming — it is new only because the old
blanket `-keepnames` had optimization switched off across the program, which
is the thing Play was flagging.
- **Retrace therefore returns more than the old raw traces did**: it expands
the frames R8 inlined instead of collapsing them into one misleading line. A
one-frame crash can retrace to three.
The workflow is `scripts/retrace.sh <mapping> [trace]`, documented in
[`RELEASE_OPS.md` § 7](../../../../RELEASE_OPS.md). Every GitHub Release carries
`amethyst-{googleplay,fdroid}-mapping-<version>.txt.gz`; Play Console needs
nothing because AGP embeds the mapping in the `.aab` under
`BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map`.
### Verifying a change to these rules
R8 cannot see reflection, so a wrong keep rule fails **only in a release build,
at runtime**. Before changing them:
```bash
./gradlew :amethyst:assemblePlayRelease -PdisableAbiSplits=true -PdisableUniversalApk=true
```
then read `amethyst/build/outputs/mapping/playRelease/`:
- `configuration.txt` — every rule R8 actually saw, including each AAR's
consumer rules. This is the file that answers "does library X already keep
itself?"
- `mapping.txt` — what got renamed. Lines whose left and right sides are equal
are classes a keep rule pinned; scan them for anything you did not intend.
- `seeds.txt` / `usage.txt` — what the keeps matched, and what was removed.
### The contract check
R8 cannot see reflection, so a keep rule that quietly stops matching — a class
moved to another package, a rule deleted in a merge, a DTO renamed — gives you a
green build and an APK that breaks on a user's device.
`tools/r8-verify/reflection-contract.txt` lists every name resolved from outside
the DEX, and the verifier asserts each against what R8 actually emitted:
```bash
python3 tools/r8-verify/verify_reflection_contract.py \
amethyst/build/outputs/mapping/playRelease/
```
Under a second, no device. The release workflow runs it for both flavors before
collecting assets, so a break fails the release instead of shipping.
**Adding reflection means adding two things**: the keep rule, and a line in the
contract. A rule with no contract line is unverified and will rot.
Scope a line to one flavor with a leading `@play` / `@fdroid` when the class is
only compiled into that variant — play-only code is absent from the F-Droid APK,
and "absent" is indistinguishable from "R8 deleted it", so an unscoped line for
it fails that release.
Reflection that names a class R8 *cannot* rename needs no rule and no line: the
platform trust manager `quic` probes for a 3-arg `checkServerTrusted` ships in
Android, not in our DEX. The opposite case is the one to watch — a name of
*ours* used as a value. `AmethystAppFunctions` compared
`signer::class.qualifiedName` against `"…NostrSignerExternal"`; R8 renames that
class, so the branch silently stopped running the day obfuscation was turned on.
Prefer `is` over a name comparison; there is no keep rule that makes the latter
safe to write.
It reads both `mapping.txt` and `usage.txt`, because neither is enough alone —
mapping.txt records only what *changed* (an intact `-keep ... { *; }` class has
an empty body, and an unrenamed member has no line at all, so absence there
means "preserved"), while a member R8 *deleted* appears only in usage.txt. It
also cross-checks that the two files come from the same R8 run: mapping.txt is
written during packaging, not at minify time, so a minify-only rebuild leaves a
stale one behind next to a fresh usage.txt.
What it cannot cover is reflection nobody wrote down. For that: a staged Play
rollout (the crash reporter retraces itself, so breaks are legible within
hours), and exercising NIP-47 wallet connect, NIP-46 bunker login, Tor,
scheduled posts and a settings round-trip (change theme/font, kill, relaunch) on
a minified build — those are the paths the keeps above exist for.
## Build Configuration
### Enable R8 in build.gradle
@@ -356,13 +304,15 @@ adb install app/build/outputs/apk/release/app-release.apk
### Issue: Compose Navigation Crashes
**Cause:** @Serializable route classes were obfuscated.
**Not** the route classes being obfuscated — that cannot happen. A type-safe
route's pattern comes from its kotlinx-serialization descriptor, and the compiler
plugin bakes the serial name and every property name in as string literals, so
renaming the class leaves the route string untouched. Adding
`-keep @kotlinx.serialization.Serializable class …routes.** { *; }` pins a large
tree for no reason and hides the real cause.
**Solution:**
```proguard
# Keep all route classes
-keep @kotlinx.serialization.Serializable class com.vitorpamplona.amethyst.ui.navigation.routes.** { *; }
```
Look instead at whether `navigation-common`'s own consumer rules made it into
`configuration.txt`, and at the stack trace retraced through `mapping.txt`.
### Issue: Native Library Crashes
+10
View File
@@ -16,11 +16,21 @@ echo "Running test... "
# variants of :amethyst (play/fdroid × debug/release/benchmark) plus full
# native-libs merging per variant — ~6× the work of one variant. CI runs the
# multi-flavor matrix on push to main; pre-push only needs one happy path.
#
# Every KMP module's tests live under <module>:jvmTest - the Kotlin Multiplatform
# plugin registers no plain `test` task - so a module missing from this list is a
# module nobody runs before pushing. Add new KMP modules here when they gain tests.
#
# `./gradlew test` now reaches the KMP modules too (see the `test` alias in the root
# build), but this hook stays explicit: a bare `test` would also pull in :desktopApp:test,
# which needs a display server and is deliberately skipped on CLAUDE_CODE_REMOTE below.
TASKS=(
:quartz:jvmTest
:commons:jvmTest
:commonsUI:jvmTest
:nestsClient:jvmTest
:quic:jvmTest
:marmotQuic:jvmTest
:amethyst:testPlayDebugUnitTest
:cli:test
)
+4 -3
View File
@@ -102,15 +102,16 @@ jobs:
- name: Test + Build Desktop (gradle)
run: |
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :commonsUI:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :commonsUI:jvmTest :quic:jvmTest :nestsClient:jvmTest :marmotQuic:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
if [ "${{ runner.os }}" = "Linux" ]; then
xvfb-run --auto-servernum $CMD
else
$CMD
fi
# This job runs five test suites (:quartz, :commons, :nestsClient, :cli,
# :desktopApp) but, unlike test-geode / test-quartz-ios /
# This job runs the JVM test suites (:quartz, :commons, :commonsUI, :quic,
# :nestsClient, :marmotQuic, :cli, :desktopApp) but, unlike test-geode /
# test-quartz-ios /
# test-and-build-android, published nothing when one of them failed. The
# console line names the failing test and the exception class and stops
# there, so the message is lost with the runner. That is how the
+45
View File
@@ -1018,6 +1018,23 @@ jobs:
env:
BUILD_TOOLS_VERSION: "36.0.0"
# R8 cannot see reflection, so a keep rule that silently stops matching — a
# class moved to another package, a rule deleted, a DTO renamed — produces
# a green build and an APK that fails on a user's device. This asserts the
# reflective surface (JNI symbols, Jackson DTO field names, enum constants
# persisted in DataStore, WorkManager worker class names, the Cast
# OptionsProvider named in a manifest meta-data value) actually survived,
# against what R8 emitted for BOTH flavors. Runs before the assets are
# collected so a break fails the release rather than shipping.
- name: Verify R8 reflection contract
run: |
set -euo pipefail
for variant in playRelease fdroidRelease; do
echo "== $variant"
python3 tools/r8-verify/verify_reflection_contract.py \
"amethyst/build/outputs/mapping/${variant}/"
done
- name: Collect Android assets (rename to canonical scheme)
run: |
set -euo pipefail
@@ -1041,6 +1058,34 @@ jobs:
"dist/amethyst-googleplay-${TAG}.aab"
cp "amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab" \
"dist/amethyst-fdroid-${TAG}.aab"
# R8 mapping files — the ONLY way a crash report from this build is
# ever readable again. The release build is minified, so every class
# in every artifact above reports `r8-map-id-<hash>` as its source
# file and a renamed class/method; `scripts/retrace.sh <mapping>`
# turns that back into real names, files and lines (and expands the
# frames R8 inlined).
#
# Play Console deobfuscates by itself because AGP embeds the mapping
# in the .aab it was given. Nothing else does: a trace from an
# F-Droid, Zapstore, Accrescent or GitHub-APK user is unreadable
# without the matching file, and the mapping only exists on this
# runner. If it is not published here it is gone when the job ends.
#
# Gzipped because the raw text mapping is ~500 MB (~29 MB
# compressed). retrace.sh reads the .gz directly.
for flavor in play fdroid; do
case "$flavor" in
play) name=googleplay ;;
fdroid) name=fdroid ;;
esac
src="amethyst/build/outputs/mapping/${flavor}Release/mapping.txt"
if [ ! -f "$src" ]; then
echo "::error::$src is missing — the release would ship with no way to read its crash reports."
exit 1
fi
gzip -c "$src" > "dist/amethyst-${name}-mapping-${TAG}.txt.gz"
done
ls -la dist
# Accrescent does not accept AABs or monolithic APKs — it requires a signed
+41 -9
View File
@@ -89,8 +89,8 @@ cd amethyst
## Generated & vendored artifacts
Two build inputs are **generated by tools but committed to the repo**, so a
normal build or release does **not** run either — Gradle just consumes the
Three build inputs are **generated by tools but committed to the repo**, so a
normal build or release does **not** run any of them — Gradle just consumes the
checked-in output. You only regenerate them under the specific conditions below,
and each has its own guide:
@@ -98,16 +98,44 @@ and each has its own guide:
|---|---|---|---|
| **Material Symbols subset font** | `commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` |
| **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) |
| **zxing-cpp (QR decoder) native libs** | `amethyst/src/main/jniLibs/*/libzxingcpp_android.so` | You bump `ZXING_CPP_VERSION`, change the JNI wrapper, or want to reproduce the binaries | [`tools/zxing-cpp-build/README.md`](tools/zxing-cpp-build/README.md) |
> **Material Symbols is mandatory after icon changes.** The bundled font is a
> ~210-glyph subset; a new codepoint that isn't in it renders as tofu (□) at
> runtime. Regenerate and commit the `.ttf` alongside the `MaterialSymbols.kt`
> change. Reusing an existing codepoint needs no regeneration.
Both tools have their own prerequisites (`fonttools`/`brotli` for the font; a
Rust toolchain + the exact Android NDK revision pinned in
`tools/arti-build/ANDROID_NDK_VERSION` for Arti) documented in their READMEs —
they are **not** required to build Amethyst from the committed sources.
Each tool has its own prerequisites (`fonttools`/`brotli` for the font; a Rust
toolchain for Arti; `cmake` + `ninja` for zxing-cpp; and, for both native
builds, the exact Android NDK revision pinned in
`tools/arti-build/ANDROID_NDK_VERSION`) documented in their READMEs — none of
them are required to build Amethyst from the committed sources.
That NDK pin is a single file for the whole repo, not a copy per tool:
`build-arti.sh`, `build-zxingcpp.sh` and `:amethyst`'s `ndkVersion` all read it,
so bumping it moves every native build and the packaging toolchain together and
they cannot drift apart. (It lives under `tools/arti-build/` for history; it is
not Arti's alone.)
The NDK half of that pin reaches the ordinary Android build. AGP strips every
native library it packages with the NDK's `llvm-strip`, so `:amethyst` sets
`ndkVersion` from `ANDROID_NDK_VERSION` — one revision for the libraries we
build and the ones we merge from dependencies. Both of our own libraries are
then excluded from that strip step (`packaging.jniLibs.keepDebugSymbols`):
they are already stripped by the pinned toolchain, so skipping the pass costs
no size and lets the `.so` inside an APK be compared byte-for-byte against the
committed, independently reproducible one. The Rust toolchain stays irrelevant
either way; Studio/AGP fetches the pinned NDK on demand, or pre-install it with
`sdkmanager "ndk;$(cat tools/arti-build/ANDROID_NDK_VERSION)"`.
> **Every ABI split needs its own copy of each library.** The APK is split four
> ways (`arm64-v8a`, `x86_64`, `armeabi-v7a`, `x86`). A split missing
> `libarti_android.so` installs and runs with Tor silently unavailable; one
> missing `libzxingcpp_android.so` installs with the QR scanner broken. The
> `verifyNativeAbis` Gradle task fails the build if a library's ABI list drifts
> from the split list, and names the `build-arti.sh --target=…` /
> `build-zxingcpp.sh --abi …` to run — and rejects a file that is not an ELF
> built for that architecture, which a missing-file check would pass.
---
@@ -334,7 +362,7 @@ Quartz library in one pipeline.
3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min).
4. **Verify** — the GH Release should hold **47 assets**:
4. **Verify** — the GH Release should hold **49 assets**:
- **14 desktop**, one per matrix leg × format:
- macOS arm64: `dmg` (1)
- Windows x64: `msi` + portable `zip` (2)
@@ -349,8 +377,12 @@ Quartz library in one pipeline.
ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why
the x64 leg gets an MSI). Revisit if that image gains WiX, or if
jpackage learns the WiX 4+ `wix build` CLI.
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
F-Droid `.apks` set built for Accrescent.
- **15 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
F-Droid `.apks` set built for Accrescent + **2 R8 mapping files**
(`amethyst-{googleplay,fdroid}-mapping-<version>.txt.gz`). The mappings
are not optional extras: the release build is minified, so without them
no crash report from an APK/`.apks` user can be read. See
[`RELEASE_OPS.md` § Crash reports](RELEASE_OPS.md#7-crash-reports--retrace).
- **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64),
`deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the
one arch-independent no-JRE `amy-<ver>-jvm.tar.gz` for Homebrew-core.
+115 -1
View File
@@ -130,6 +130,11 @@ Nothing to do beyond pushing the tag. Verify the asset count (BUILDING.md
§ Verify). macOS is **arm64-only** — there is no Intel DMG, so a single
`amethyst-desktop-<version>-macos-arm64.dmg` is the expected, correct result.
Two of those assets are the R8 mapping files
(`amethyst-{googleplay,fdroid}-mapping-<version>.txt.gz`). Do not prune them
from old releases — they are the only way to read a crash report from a build
that old (§ 7).
### Google Play — manual upload
1. Download `amethyst-googleplay-<version>.aab` from the GH Release.
2. Play Console → app `com.vitorpamplona.amethyst` → **Production** (or the
@@ -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.
+176 -4
View File
@@ -67,6 +67,27 @@ afterEvaluate {
}
}
// Every ABI we split the APK for, and therefore every ABI that needs its own copy of each
// library we build and commit ourselves under src/main/jniLibs/. The lists drifted once: the
// splits shipped four ABIs while Arti was built for two, so the armeabi-v7a and x86 APKs
// installed and ran with the dependencies' native libraries all present (secp256k1's JNI ships
// every ABI) and Tor alone dead for the life of the install. `verifyNativeAbis` below keeps
// them in step.
val shippedAbis = listOf("x86", "x86_64", "arm64-v8a", "armeabi-v7a")
// The libraries that guard covers, and how to rebuild one when it is missing. Both are built
// from source by tools/ rather than pulled prebuilt, so both can go missing the same way — and
// a QR scanner that cannot load is as silently broken on that install as a dead Tor.
val committedNativeLibs =
mapOf(
"libarti_android.so" to { abi: String, triple: String ->
"./tools/arti-build/build-arti.sh --target=$triple"
},
"libzxingcpp_android.so" to { abi: String, _: String ->
"./tools/zxing-cpp-build/build-zxingcpp.sh --abi $abi"
},
)
android {
namespace = "com.vitorpamplona.amethyst"
compileSdk =
@@ -74,6 +95,33 @@ android {
.get()
.toInt()
// Packaging toolchain: AGP runs the NDK's llvm-strip over everything that
// lands in jniLibs — our committed libarti_android.so included — so the
// NDK revision is a build input for the APK, not just for whoever compiles
// Arti. Left unset it silently follows AGP's own default (28.2.13676358 on
// AGP 9.4.0), which moves with every AGP bump and is a different toolchain
// from the one that produced the .so, while a machine with no NDK at all
// packages the library unstripped ("Unable to strip the following
// libraries") — three different APKs from the same source, which is
// exactly what F-Droid's rebuild verification cannot have.
//
// Read straight from the pin rather than copied into the version catalog:
// the two can then never drift, and bumping ANDROID_NDK_VERSION (which also
// means rebuilding the .so files) moves the packaging toolchain with it.
// That one file is the repo's only NDK pin -- tools/arti-build/build-arti.sh
// and tools/zxing-cpp-build/build-zxingcpp.sh read it too, so every
// committed .so is produced and stripped by the same revision. It lives
// under tools/arti-build for history; it is not Arti's alone. See
// tools/arti-build/README.md → "Reproducible builds".
ndkVersion =
providers
.fileContents(layout.settingsDirectory.file("tools/arti-build/ANDROID_NDK_VERSION"))
.asText
.orNull
?.trim()
?.takeIf { it.isNotEmpty() }
?: error("tools/arti-build/ANDROID_NDK_VERSION is missing or empty — it pins the NDK that strips src/main/jniLibs")
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk =
@@ -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
```
+1
View File
@@ -11,6 +11,7 @@ _Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0
## Queued
| Plan | Summary |
| ---- | ------- |
| [2026-09-15-qr-reader-overhaul.md](2026-09-15-qr-reader-overhaul.md) | QR reader rebuilt on CameraX + zxing-cpp in-app (replacing the Camera1 zxing-android-embedded activity) — continuous AF, zoom, torch, full-frame decode, explicit failure feedback, and gallery/clipboard import. |
| [2026-07-23-push-notification-redesign.md](2026-07-23-push-notification-redesign.md) | Per-kind tray notification redesign — accent colors, status-bar icons, MessagingStyle/BigPictureStyle/colorized zap cards, aggregation, Conversations/Bubbles; closes nutzap/onchain/repost/badge parity gaps. |
| [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. |
+229 -56
View File
@@ -1,71 +1,244 @@
# 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 { *; }
@@ -0,0 +1,16 @@
blur 3 3
blur-heavy 0 3
clean 3 3
far-1.5px 0 3
far-2px 2 3
far-3px 3 3
glare 3 3
inverted 3 3
low-contrast 3 3
moire 3 3
noise 2 3
perspective 0 3
tilt15 2 3
tilt30 2 3
tilt45 2 3
very-low-contrast 0 3
1 blur 3 3
2 blur-heavy 0 3
3 clean 3 3
4 far-1.5px 0 3
5 far-2px 2 3
6 far-3px 3 3
7 glare 3 3
8 inverted 3 3
9 low-contrast 3 3
10 moire 3 3
11 noise 2 3
12 perspective 0 3
13 tilt15 2 3
14 tilt30 2 3
15 tilt45 2 3
16 very-low-contrast 0 3
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

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

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
@@ -163,7 +162,7 @@ class ThreadDualAxisChartAssemblerTest {
fun threadOrderTest() =
runBlocking {
val eventArray =
JacksonMapper.mapper.readValue<List<Event>>(db) + Event.fromJson(header)
JacksonMapper.mapper.readValue<List<Event>>(db, JacksonMapper.eventListTypeInstance) + Event.fromJson(header)
var counter = 0
eventArray.forEach {
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
/**
* Runs zxing-cpp over the committed [QrCorpus] images and asserts it reads at least as many as
* ZXing-Java did, category by category.
*
* This is the gate the plan's phase 0 asks for: the justification for replacing the decoder is
* supposed to be a measurement, not an argument. The baseline it compares against was produced on
* the JVM by `QrCorpusBaselineTest` from the same images.
*
* Regenerate both with:
* ```
* ./gradlew :amethyst:testFdroidDebugUnitTest --tests '*QrCorpusBaselineTest*' \\
* -Pamethyst.qr.corpus.export=true
* ```
*/
@RunWith(AndroidJUnit4::class)
class QrDecodeCorpusTest {
private val assets = InstrumentationRegistry.getInstrumentation().context.assets
private val decoder = ZxingCppBarcodeDecoder()
@Test
fun readsEveryCleanCode() {
fixtures().filter { it.category == "clean" }.forEach {
assertTrue("clean fixture ${it.file} must decode", decode(it))
}
}
@Test
fun beatsTheOldDecoderInEveryCategory() {
val baseline = baseline()
assertTrue("baseline.tsv missing - regenerate the corpus", baseline.isNotEmpty())
val results = fixtures().groupBy { it.category }.mapValues { (_, list) -> list.count { decode(it) } }
val regressions = mutableListOf<String>()
val report =
buildString {
appendLine()
appendLine("category zxing-cpp ZXing-Java")
baseline.keys.sorted().forEach { category ->
val old = baseline.getValue(category)
val new = results[category] ?: 0
appendLine(" %-20s %d/%d %d/%d".format(category, new, old.total, old.passed, old.total))
if (new < old.passed) regressions += "$category: $new < ${old.passed}"
}
}
println(report)
if (regressions.isNotEmpty()) {
fail("zxing-cpp read fewer codes than ZXing-Java in: ${regressions.joinToString("; ")}$report")
}
}
private data class Fixture(
val file: String,
val category: String,
val expected: String,
)
private data class Baseline(
val passed: Int,
val total: Int,
)
private fun decode(fixture: Fixture): Boolean {
val bitmap =
assets.open("$ASSET_DIR/${fixture.file}").use {
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 })
} ?: return false
return try {
decoder.decode(bitmap, DecodeEffort.Thorough).any { it.text == fixture.expected }
} finally {
bitmap.recycle()
}
}
private fun fixtures(): List<Fixture> =
assets.open("$ASSET_DIR/expected.tsv").bufferedReader().useLines { lines ->
lines
.filter { it.isNotBlank() }
.map { line ->
val (file, category, expected) = line.split('\t', limit = 3)
Fixture(file, category, expected)
}.toList()
}
private fun baseline(): Map<String, Baseline> =
assets.open("$ASSET_DIR/baseline.tsv").bufferedReader().useLines { lines ->
lines.filter { it.isNotBlank() }.associate { line ->
val (category, passed, total) = line.split('\t', limit = 3)
category to Baseline(passed.toInt(), total.toInt())
}
}
companion object {
private const val ASSET_DIR = "qr"
}
}
+27 -6
View File
@@ -188,6 +188,14 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Long-press the launcher icon to scan a QR code. The shortcut fires a VIEW intent
at nostr:scanqr, which the nostr-scheme filter below already catches, so it needs
no hardcoded targetPackage - debug and benchmark builds carry an applicationId
suffix that would break one. uriToRoute turns it into the scanner. -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -377,6 +385,25 @@
</intent-filter>
</activity-alias>
<!-- "Scan QR code" share target: an image shared here is decoded rather than posted, which
is how most QR codes actually arrive - a screenshot or a photo someone sent you. SEND
only: a QR scan reads one code, so there is nothing sensible to do with a multi-select.
The android:name simple class ("ScanQrCodeAlias") is matched at runtime by
ShareIntentRouting.SCAN_QR_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ScanQrCodeAlias"
android:exported="true"
android:label="@string/share_target_scan_qr"
android:icon="@drawable/ic_qrcode"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_scan_qr">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity-alias>
<!-- "New Short" share target: a video shared here always becomes a NIP-71 kind-22 short so
it lands in the Shorts feed, whatever its orientation. Video-only — a short is a video.
The android:name simple class ("ShareAsShortVideoAlias") is matched at runtime by
@@ -470,12 +497,6 @@
</intent-filter>
</activity-alias>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation"
tools:ignore="DiscouragedApi" />
<activity
android:name=".ui.screen.loggedIn.nests.room.activity.NestActivity"
android:autoRemoveFromRecents="true"
@@ -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
@@ -48,7 +48,12 @@ class ReportAssembler {
threadName: String = Thread.currentThread().name,
): String =
buildString {
append(e.javaClass.simpleName)
// Fully qualified, NOT simpleName. Release builds are obfuscated, so this
// headline would otherwise read "a: 1.16.0-PLAY" — and a bare simple name
// carries no package for `retrace` to resolve it against, so it would stay
// unreadable even after the rest of the report retraced cleanly. The FQN
// retraces back to the real exception class. See scripts/retrace.sh.
append(e.javaClass.name)
append(": ")
appendLine(BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase())
appendLine()
@@ -101,8 +106,11 @@ class ReportAssembler {
append("Thread: ")
appendLine(threadName)
appendLine(e.headline())
// " at <frame>", not just " <frame>": `at` is what every stack-trace
// parser keys on, R8's `retrace` included. Without it a release report is
// passed through untouched and stays obfuscated. See scripts/retrace.sh.
e.stackTrace.forEach {
append(" ")
append(" at ")
appendLine(it.toString())
}
val cause = e.cause
@@ -111,7 +119,7 @@ class ReportAssembler {
append(" ")
appendLine(cause.headline())
cause.stackTrace.forEach {
append(" ")
append(" at ")
appendLine(it.toString())
}
}
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.lnurl
import android.content.Context
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.databind.ObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.ui.stringRes
@@ -167,7 +167,7 @@ class LightningAddressResolver {
val errorMessage =
runCatching {
jacksonObjectMapper().readTree(body)
ObjectMapper().readTree(body)
}.getOrNull()?.let { tree ->
val errorNode = tree.get("error")
val messageNode = tree.get("message")
@@ -218,7 +218,7 @@ class LightningAddressResolver {
context: Context,
onZapRequestSent: (LnZapRequestEvent?) -> Unit = {},
): String {
val mapper = jacksonObjectMapper()
val mapper = ObjectMapper()
val lnurlpUrl = assembleUrl(lnAddress)
@@ -20,9 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.pow
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPersistence
import com.vitorpamplona.quartz.utils.Log
@@ -32,6 +29,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
/**
@@ -46,9 +45,18 @@ class PowJobStore(
private val storageFile: File,
scope: CoroutineScope,
) : PoWJobPersistence {
private val mapper =
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
/**
* `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx
* omits a value equal to its default, which would silently drop `"version":1`
* from every file this build rewrites. `ignoreUnknownKeys` matches the
* FAIL_ON_UNKNOWN_PROPERTIES=false it replaces, so a file written by a newer
* build still loads here.
*/
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
// one lane: launch order == execution order, so a save followed by its
// remove can never be applied backwards.
@@ -103,7 +111,7 @@ class PowJobStore(
jobs =
try {
if (storageFile.exists() && storageFile.length() > 0) {
mapper.readValue<PowJobsFile>(storageFile).jobs.toMutableList()
json.decodeFromString<PowJobsFile>(storageFile.readText()).jobs.toMutableList()
} else {
mutableListOf()
}
@@ -120,7 +128,7 @@ class PowJobStore(
storageFile.parentFile?.mkdirs()
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
try {
mapper.writeValue(tmp, PowJobsFile(version = 1, jobs = jobs.toList()))
tmp.writeText(json.encodeToString(PowJobsFile(version = 1, jobs = jobs.toList())))
if (!tmp.renameTo(storageFile)) {
if (!storageFile.delete() || !tmp.renameTo(storageFile)) {
Log.e(TAG) { "Failed to rename $tmp to $storageFile" }
@@ -147,6 +155,7 @@ class PowJobStore(
}
}
@Serializable
data class PowJobsFile(
val version: Int = 1,
val jobs: List<PersistedPoWJob> = emptyList(),
@@ -20,12 +20,11 @@
*/
package com.vitorpamplona.amethyst.service.resourceusage
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
/**
@@ -60,6 +59,7 @@ class ResourceUsageStore(
*/
private val keepDays: Long = 7,
) {
@Serializable
data class UsageFile(
val version: Int = 1,
val days: Map<String, Map<String, Long>> = emptyMap(),
@@ -67,9 +67,18 @@ class ResourceUsageStore(
val alertsOptOut: Boolean = false,
)
private val mapper =
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
/**
* `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx
* omits a value equal to its default, which would silently drop `"version":1`
* from every file this build rewrites. `ignoreUnknownKeys` matches the
* FAIL_ON_UNKNOWN_PROPERTIES=false it replaces, so a file written by a newer
* build still loads here.
*/
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val mutex = Mutex()
private var loaded = false
@@ -133,7 +142,7 @@ class ResourceUsageStore(
data =
try {
if (storageFile.exists() && storageFile.length() > 0) {
mapper.readValue<UsageFile>(storageFile)
json.decodeFromString<UsageFile>(storageFile.readText())
} else {
UsageFile()
}
@@ -148,7 +157,7 @@ class ResourceUsageStore(
storageFile.parentFile?.mkdirs()
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
try {
mapper.writeValue(tmp, data)
tmp.writeText(json.encodeToString(data))
if (!tmp.renameTo(storageFile)) {
if (!storageFile.delete() || !tmp.renameTo(storageFile)) {
Log.e(TAG) { "Failed to rename $tmp to $storageFile" }
@@ -26,7 +26,7 @@ import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.databind.ObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -209,7 +209,7 @@ class Nip96Uploader {
val errorMessage =
try {
val tree = jacksonObjectMapper().readTree(msg)
val tree = ObjectMapper().readTree(msg)
val status = tree.get("status")?.asText()
val message = tree.get("message")?.asText()
if (status == "error" && message != null) {
@@ -38,7 +38,15 @@ import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.time.Duration
import java.time.Instant
import kotlin.math.roundToInt
@@ -66,6 +74,16 @@ class HealthConnectManager(
/** How far back the New Workout carousel looks for workouts to offer. */
const val LOOKBACK_DAYS = 7L
/**
* How many per-session metric aggregations may be in flight at once.
*
* Each is an independent binder transaction into the Health Connect provider, so running
* them in series made the read scale with the window length. Capped rather than unbounded
* because the provider serves them from a finite thread/transaction pool, and a four-week
* window for a daily trainer would otherwise fire dozens at once.
*/
private const val MAX_CONCURRENT_AGGREGATES = 6
private const val DEFAULT_SOURCE = "Health Connect"
/** Friendly names for well-known writers when their app isn't installed to read a label from. */
@@ -124,49 +142,153 @@ class HealthConnectManager(
}
/**
* All exercise sessions that ended within [since]..[now], mapped to
* [DetectedWorkout]. Sessions whose activity type Amethyst cannot represent
* are skipped. Returns an empty list (never throws) if Health Connect is
* unavailable or a read fails.
* One stage of a progressive Health Connect read. See [readWorkoutsProgressively].
*/
data class WorkoutRead(
val workouts: List<DetectedWorkout>,
/**
* True while the per-session aggregate metrics (distance, calories, heart rate, steps,
* elevation) are still being fetched, so [workouts] currently carries nulls for them.
*/
val metricsPending: Boolean,
)
/**
* All exercise sessions that ended within [since]..[now], mapped to [DetectedWorkout], in two
* stages.
*
* The session list itself is one IPC. Every optional metric — distance, calories, heart rate,
* steps, elevation — needs a *separate* [aggregate] round trip per session, and a four-week
* window for someone who trains daily is dozens of them. Waiting for all of that before
* showing anything is what made the My Fitness dashboard sit on a spinner for seconds.
*
* So this emits twice:
* 1. the sessions with what the read already knows — activity, title, start, duration,
* source — and [WorkoutRead.metricsPending] true;
* 2. the same workouts with their metrics filled in, and the flag false.
*
* That first emission already carries everything the dashboard's counts, durations, streak,
* active days and per-activity split need. `WorkoutStats` totals and bests treat an absent
* metric as contributing nothing rather than zero, so the partial pass is honest rather than
* wrong — a distance cell is missing until it is known, not shown as 0.
*
* A single-emission caller that wants the finished numbers takes [readWorkouts].
*
* Aggregation is per *raw* session and merging happens after it, exactly as a one-shot read
* did: [WorkoutMerger] sums the members' metrics, so aggregating a merged span instead would
* fold in the breaks between segments and change the totals.
*
* Emits a single empty result (never throws) if Health Connect is unavailable or the read
* fails.
*/
fun readWorkoutsProgressively(
since: Instant,
now: Instant = Instant.now(),
): Flow<WorkoutRead> =
// flowOn(IO): callers collect from Dispatchers.Main, Health Connect's own calls suspend,
// but the PackageManager lookup in resolveSourceName is a blocking binder call — so the
// whole read moves off the UI thread rather than relying on each step to behave.
flow {
if (!isAvailable(context)) {
Log.i(TAG) { "readWorkouts: Health Connect unavailable (status=${HealthConnectClient.getSdkStatus(context)})" }
emit(WorkoutRead(emptyList(), metricsPending = false))
return@flow
}
val sessions =
try {
client
.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(since, now),
),
).records
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w(TAG, "Failed to read workouts from Health Connect", e)
emit(WorkoutRead(emptyList(), metricsPending = false))
return@flow
}
Log.i(TAG) { "readWorkouts: ${sessions.size} exercise session(s) in window $since .. $now" }
// Kept paired with their sessions: stage 2 aggregates over each raw session's own
// time range, which the mapped workout no longer carries once merging rewrites it.
val skeletons = sessions.mapNotNull { session -> mapSession(session)?.let { session to it } }
if (skeletons.isEmpty()) {
Log.i(TAG) { "readWorkouts: no mappable sessions" }
emit(WorkoutRead(emptyList(), metricsPending = false))
return@flow
}
emit(WorkoutRead(merge(skeletons.map { it.second }), metricsPending = true))
// Bounded fan-out rather than one-at-a-time: these are independent IPCs into the
// provider, and running them in series is what the window length multiplied. The
// permit cap keeps a month of daily training from opening 60 concurrent binder
// transactions at a provider that has a finite pool for them.
val detailed =
coroutineScope {
val gate = Semaphore(MAX_CONCURRENT_AGGREGATES)
skeletons
.map { (session, workout) ->
async { gate.withPermit { workout.withMetrics(aggregate(session)) } }
}.awaitAll()
}
val merged = merge(detailed)
Log.i(TAG) { "readWorkouts: mapped ${detailed.size} -> ${merged.size} workout(s) after type/duration filtering and merging" }
emit(WorkoutRead(merged, metricsPending = false))
}.flowOn(Dispatchers.IO)
/**
* All exercise sessions in [since]..[now], with their metrics — the finished result of
* [readWorkoutsProgressively]. For callers that have nothing useful to show from a partial
* read and so gain nothing from the intermediate stage.
*/
suspend fun readWorkouts(
since: Instant,
now: Instant = Instant.now(),
): List<DetectedWorkout> {
if (!isAvailable(context)) {
Log.i(TAG) { "readWorkouts: Health Connect unavailable (status=${HealthConnectClient.getSdkStatus(context)})" }
return emptyList()
}
): List<DetectedWorkout> = readWorkoutsProgressively(since, now).last().workouts
// The callers are composables launching into rememberCoroutineScope(), i.e.
// Dispatchers.Main. Health Connect's own calls suspend, but the PackageManager
// lookup in resolveSourceName is a blocking binder call, so the whole read
// moves off the UI thread rather than relying on each step to behave.
return withContext(Dispatchers.IO) {
try {
val response =
client.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(since, now),
),
)
Log.i(TAG) { "readWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" }
val mapped = response.records.mapNotNull { mapSession(it) }
// Fold split-up sessions of the same activity (a long run broken around
// breaks) into one suggestion so the composer offers the whole effort.
val merged = WorkoutMerger.mergeCloseWorkouts(mapped)
Log.i(TAG) { "readWorkouts: mapped ${mapped.size} -> ${merged.size} workout(s) after type/duration filtering and merging" }
merged
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w(TAG, "Failed to read workouts from Health Connect", e)
emptyList()
}
}
/**
* Folds split-up sessions of the same activity (a long run broken around breaks) into one
* workout so the composer offers the whole effort.
*/
private fun merge(workouts: List<DetectedWorkout>) = WorkoutMerger.mergeCloseWorkouts(workouts)
/** Copies [totals] — the result of one [aggregate] call — onto a session skeleton. */
private fun DetectedWorkout.withMetrics(totals: AggregationResult?): DetectedWorkout {
if (totals == null) return this
return copy(
distanceMeters = totals[DistanceRecord.DISTANCE_TOTAL]?.inMeters,
// Prefer active calories (what RUNSTR publishes); fall back to total for
// sources that only record total energy. Total includes basal burn, so it
// over-reports the workout if used as the primary figure.
calories =
(
totals[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
?: totals[TotalCaloriesBurnedRecord.ENERGY_TOTAL]?.inKilocalories
)?.roundToInt(),
avgHeartRate = totals[HeartRateRecord.BPM_AVG]?.toInt(),
maxHeartRate = totals[HeartRateRecord.BPM_MAX]?.toInt(),
steps = totals[StepsRecord.COUNT_TOTAL]?.toInt(),
elevationGainMeters = totals[ElevationGainedRecord.ELEVATION_GAINED_TOTAL]?.inMeters,
)
}
private suspend fun mapSession(session: ExerciseSessionRecord): DetectedWorkout? {
/**
* The session as the record itself describes it: activity, title, when, how long, who wrote
* it. The optional metrics are left null for [withMetrics] to fill in — they each cost their
* own IPC, so they are not part of mapping a session.
*
* Null when the activity type is one Amethyst cannot represent, or the session has no
* positive duration.
*/
private fun mapSession(session: ExerciseSessionRecord): DetectedWorkout? {
val exercise = ExerciseTypeMapper.toExerciseType(session.exerciseType)
if (exercise == null) {
Log.i(TAG) {
@@ -187,27 +309,18 @@ class HealthConnectManager(
"from ${session.metadata.dataOrigin.packageName}"
}
val totals = aggregate(session)
return DetectedWorkout(
id = session.metadata.id,
exercise = exercise,
title = session.title?.takeIf { it.isNotBlank() },
startTimeEpochSeconds = session.startTime.epochSecond,
durationSeconds = durationSeconds,
distanceMeters = totals?.get(DistanceRecord.DISTANCE_TOTAL)?.inMeters,
// Prefer active calories (what RUNSTR publishes); fall back to total for
// sources that only record total energy. Total includes basal burn, so it
// over-reports the workout if used as the primary figure.
calories =
(
totals?.get(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)?.inKilocalories
?: totals?.get(TotalCaloriesBurnedRecord.ENERGY_TOTAL)?.inKilocalories
)?.roundToInt(),
avgHeartRate = totals?.get(HeartRateRecord.BPM_AVG)?.toInt(),
maxHeartRate = totals?.get(HeartRateRecord.BPM_MAX)?.toInt(),
steps = totals?.get(StepsRecord.COUNT_TOTAL)?.toInt(),
elevationGainMeters = totals?.get(ElevationGainedRecord.ELEVATION_GAINED_TOTAL)?.inMeters,
distanceMeters = null,
calories = null,
avgHeartRate = null,
maxHeartRate = null,
steps = null,
elevationGainMeters = null,
source = resolveSourceName(session.metadata.dataOrigin.packageName),
)
}
@@ -210,6 +210,21 @@ fun isMarmotGroupRoute(uri: String) = uri.startsWith("marmot:")
*/
fun isActiveSubscriptionsRoute(uri: String) = uri.startsWith("activesubs", true) || uri.startsWith("nostr:activesubs", true)
/**
* The launcher shortcut (res/xml/shortcuts.xml) fires `amethyst:scanqr`, caught by MainActivity's
* existing scheme filter. A pseudo-uri rather than a hardcoded component, because debug and
* benchmark builds carry an applicationId suffix a hardcoded targetPackage would miss, and
* manifest placeholders are not substituted into resource XML.
*
* `nostr:scanqr` is still accepted so an older pinned shortcut keeps working, but the shortcut
* itself uses our own scheme: an implicit `nostr:` VIEW intent can be answered by any Nostr client
* installed alongside us.
*/
fun isScanQrRoute(uri: String) =
uri.equals("scanqr", true) ||
uri.equals("amethyst:scanqr", true) ||
uri.equals("nostr:scanqr", true)
private val MARMOT_HEX = Regex("^[0-9a-fA-F]+$")
fun uriToRoute(
@@ -223,6 +238,9 @@ fun uriToRoute(
if (isActiveSubscriptionsRoute(uri)) {
return Route.ActiveSubscriptions
}
if (isScanQrRoute(uri)) {
return Route.QRDisplay(account.signer.pubKey, startScanning = true)
}
if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix(NOSTR_URI_PREFIX).removePrefix("hashtag?id=").lowercase())
}
@@ -254,6 +254,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.ProductsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.SendPaymentScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.PublicChatsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ScanQrImageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
@@ -594,7 +595,8 @@ fun BuildNavigation(
composableFromBottomArgs<Route.EmojiPackMetadataEdit> { EmojiPackMetadataScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.EmojiPackSelection> { EmojiPackSelectionScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromBottomArgs<Route.QRDisplay> { ShowQRScreen(it.pubkey, accountViewModel, nav) }
composableFromBottomArgs<Route.QRDisplay> { ShowQRScreen(it.pubkey, accountViewModel, nav, it.startScanning) }
composableFromBottomArgs<Route.ScanQrImage> { ScanQrImageScreen(it.uri, accountViewModel, nav) }
composableFromBottomArgs<Route.ManualZapSplitPayment> { PayViaIntentScreen(it.paymentId, accountViewModel, nav) }
@@ -1106,6 +1108,9 @@ private fun NavigateIfIntentRequested(
ShareTarget.DIRECT_MESSAGE -> if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
ShareTarget.NEW_POST -> if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
ShareTarget.PICTURE, ShareTarget.SHORT_VIDEO, ShareTarget.VIDEO -> Unit
// Always re-runs: the route carries the image, so a second share of a different
// picture must decode that one rather than sit on the previous result.
ShareTarget.SCAN_QR -> Unit
}
// saves the intent to avoid processing again
@@ -1131,6 +1136,8 @@ private fun NavigateIfIntentRequested(
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
ShareTarget.NEW_POST -> nav.newStack(Route.NewShortNote(message = message, attachment = attachments.firstOrNull()))
ShareTarget.SCAN_QR ->
attachments.firstOrNull()?.let { nav.newStack(Route.ScanQrImage(it.toString())) }
}
// Consume the launch intent so a later recomposition can't re-fire
@@ -1235,6 +1242,8 @@ private fun NavigateIfIntentRequested(
if (!consumesSharesInPlace(nav.controller) && (message != null || attachment != null)) {
nav.newStack(Route.NewShortNote(message = message, attachment = attachment))
}
ShareTarget.SCAN_QR -> attachment?.let { nav.newStack(Route.ScanQrImage(it.toString())) }
}
} else {
val uri = intent.data?.toString()
@@ -39,6 +39,15 @@ enum class ShareTarget {
/** "New Video": a NIP-71 video, straight into the Video feed. */
VIDEO,
/**
* "Scan QR code": read a QR out of a shared picture instead of posting it.
*
* The common way a QR code reaches someone is as a screenshot or a photo in a chat, not as
* something in front of the camera. Without this the only way to use one was to display it on
* a second screen and point the phone at it.
*/
SCAN_QR,
}
/**
@@ -71,6 +80,9 @@ object ShareIntentRouting {
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsVideoAlias"
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SCAN_QR_ALIAS_SIMPLE_NAME = "ScanQrCodeAlias"
private val TARGET_BY_ALIAS =
mapOf(
SHARE_AS_DM_ALIAS_SIMPLE_NAME to ShareTarget.DIRECT_MESSAGE,
@@ -78,6 +90,7 @@ object ShareIntentRouting {
SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME to ShareTarget.PICTURE,
SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.SHORT_VIDEO,
SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.VIDEO,
SCAN_QR_ALIAS_SIMPLE_NAME to ShareTarget.SCAN_QR,
)
/**
@@ -671,6 +671,20 @@ sealed class Route {
@Serializable data class QRDisplay(
val pubkey: String,
/**
* Opens straight into the scanner instead of showing this user's own code. Set by the
* launcher shortcut, whose entire purpose is to skip that step.
*/
val startScanning: Boolean = false,
) : Route()
/**
* Decodes a QR out of an image the user shared into Amethyst, then goes wherever it points.
*
* [uri] is the shared image's content uri, as a string so the route stays serializable.
*/
@Serializable data class ScanQrImage(
val uri: String,
) : Route()
@Serializable data class ContentDiscovery(
@@ -1276,6 +1290,11 @@ fun isSameRoute(
): Boolean {
if (currentRoute == null) return false
// Opening the scanner is an action, not a place. After the user closes the scanner they are
// still on this exact entry, so treating a repeat as a duplicate made the launcher shortcut
// silently do nothing the second time. A fresh entry reopens the camera.
if (newRoute is Route.QRDisplay && newRoute.startScanning) return false
if (currentRoute == newRoute) {
return true
}
@@ -37,14 +37,12 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
@@ -61,10 +59,10 @@ import com.vitorpamplona.amethyst.commons.resources.share_as_qr_mode_nostr
import com.vitorpamplona.amethyst.commons.resources.share_as_qr_mode_web
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.KeepScreenBrightAndAwake
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
@@ -221,45 +219,3 @@ private fun ShareNoteAsQrScreenContent(
* brightness/`keepScreenOn` state an incoming screen has already set. Nothing in the current nav
* graph triggers that overlap, so this is left as a comment rather than code.
*/
@Composable
private fun KeepScreenBrightAndAwake() {
val view = LocalView.current
// NOT `(view.context as? Activity)`: under Compose the context is routinely a
// ContextThemeWrapper, so that cast silently yields null and brightness never changes —
// no crash, no log, just a dead feature. getActivityWindow() unwraps the ContextWrapper
// chain (WindowUtils.kt:39-46).
val window = getActivityWindow()
DisposableEffect(window, view) {
// Capture the RAW attribute, not a computed fraction. When no override is set this is
// BRIGHTNESS_OVERRIDE_NONE (-1f), and restoring that value returns the device to auto
// brightness. Restoring a *computed* fraction would install an override where none
// existed and silently disable auto-brightness for the rest of the session.
val previousBrightness = window?.attributes?.screenBrightness
// F8: same capture/replay discipline as brightness above, and for the same reason.
// `view` is the Activity's single shared root ComposeView, and PlayerEventListener
// (ControlWhenPlayerIsActive.kt:150-165) owns this exact flag while media plays.
// Hard-setting `false` on dispose — instead of restoring what was here before this
// screen took it over — would clobber that ownership: navigating back from the QR
// screen while audio or video is still playing would let the screen sleep mid-playback.
val previousKeepScreenOn = view.keepScreenOn
window?.let {
it.attributes = it.attributes.apply { screenBrightness = 1f }
}
view.keepScreenOn = true
onDispose {
// Restore the captured value rather than calling a release helper: resetting to
// BRIGHTNESS_OVERRIDE_NONE unconditionally would clobber an override the user
// already had, e.g. one left by the fullscreen video controls.
window?.let { w ->
previousBrightness?.let { prev ->
w.attributes = w.attributes.apply { screenBrightness = prev }
}
}
view.keepScreenOn = previousKeepScreenOn
}
}
}
@@ -21,16 +21,18 @@
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
/**
* Entry for a NIP-52 calendar RSVP: decodes the [Note] and renders the shared commons
* [CalendarRsvpCard]. Draws only from the event's own tags, so the entry keeps the
* dispatcher signature without touching the account or nav.
* Entry for a NIP-52 calendar RSVP: decodes the [Note], makes sure the appointment it answers is
* in the cache, and renders the shared commons [CalendarRsvpCard].
*/
@Composable
fun RenderCalendarRSVPEvent(
@@ -40,5 +42,40 @@ fun RenderCalendarRSVPEvent(
) {
val event = note.event as? CalendarRSVPEvent ?: return
LoadAppointmentBehind(event, accountViewModel)
CalendarRsvpCard(event)
}
/**
* Resolves the appointment this RSVP answers, from the `a` tag that is the only thing tying the
* two together.
*
* [LoadAddressableNote] creates the [com.vitorpamplona.amethyst.commons.model.AddressableNote] in
* `LocalCache` — a shell with a null event if we have never seen the appointment — and
* [EventFinderFilterAssemblerSubscription] then asks relays for it: `filterMissingAddressables`
* picks up exactly those addressables whose `event == null` and queries the address author's
* outbox relays plus any stored hints.
*
* Both halves matter beyond drawing this card. `EventBroadcaster` routes an RSVP by following its
* `a` tag into the appointment and reading the participants off it, and every step of that walk
* is a `LocalCache` lookup. An RSVP seen in a feed for an appointment that was never cached would
* otherwise leave the cache with no entry to walk, so answering it from here would reach the host
* (whose pubkey the coordinate carries) but none of the other invitees.
*
* Composition-scoped like every other per-note subscription: the row unsubscribes ~30s after it
* scrolls away or the app backgrounds.
*/
@Composable
private fun LoadAppointmentBehind(
event: CalendarRSVPEvent,
accountViewModel: AccountViewModel,
) {
val address = remember(event) { event.calendarEventAddress() } ?: return
LoadAddressableNote(address, accountViewModel) { appointment ->
if (appointment != null) {
EventFinderFilterAssemblerSubscription(appointment, accountViewModel)
}
}
}
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
@@ -191,14 +192,31 @@ private fun sendRsvp(
) {
val relayHint = LocalCache.getNoteIfExists(eventId)?.relays?.firstOrNull()
val aTag = ATag(targetAddress, relayHint)
// NIP-52's optional `e` tag: the `a` tag names the appointment's coordinate, which follows
// the host's edits, while this pins the exact revision the user answered. A reader can then
// tell an "accepted" cast against last week's time from one cast against the current one.
val eTag = ETag(eventId, relayHint, targetAddress.pubKeyHex)
val pTag = PTag(targetAddress.pubKeyHex)
val dTag = rsvpDTagFor(targetAddress)
// Only the host is p-tagged, per NIP-52 ("pubkey of the author of the calendar event being
// responded to"). The other invitees still receive this RSVP: EventBroadcaster follows the
// a-tag into the appointment and reads its participants' inbox relays from the appointment's
// own p tags (CalendarTimeSlotEvent/CalendarDateSlotEvent are PubKeyHintProviders).
//
// Copying those participants onto the RSVP as extra p tags would add no routing - the
// broadcaster's recursion and any local participant lookup read the same
// LocalCache.getAddressableNoteIfExists(targetAddress), so they are reachable in exactly the
// same cases - while giving every invitee a notification row for every other invitee's RSVP
// (kind 31925 is in NOTIFICATION_KINDS and tagsAnEventByUser returns true for it), bloating
// the signed event by a host-controlled number of tags, and muddying the spec's meaning of
// this kind's p tag.
accountViewModel.launchSigner {
accountViewModel.account.signAndComputeBroadcast(
CalendarRSVPEvent.build(
calendarEventAddress = aTag,
status = status,
calendarEventId = eTag,
calendarEventAuthor = pTag,
dTag = dTag,
),
@@ -0,0 +1,77 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalView
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
/**
* Pins the screen to full brightness, and awake, while a QR code is on it.
*
* Half of "this QR code will not scan" is the phone showing it. A dark-mode OLED at the
* auto-brightness the room asked for can put so little contrast between the black and white
* modules that the other camera's binarizer cannot separate them — and the person holding it has
* no idea that is the problem, because to a human eye the code looks perfectly clear.
*/
@Composable
fun KeepScreenBrightAndAwake() {
val view = LocalView.current
// NOT `(view.context as? Activity)`: under Compose the context is routinely a
// ContextThemeWrapper, so that cast silently yields null and brightness never changes —
// no crash, no log, just a dead feature. getActivityWindow() unwraps the ContextWrapper
// chain (WindowUtils.kt:39-46).
val window = getActivityWindow()
DisposableEffect(window, view) {
// Capture the RAW attribute, not a computed fraction. When no override is set this is
// BRIGHTNESS_OVERRIDE_NONE (-1f), and restoring that value returns the device to auto
// brightness. Restoring a *computed* fraction would install an override where none
// existed and silently disable auto-brightness for the rest of the session.
val previousBrightness = window?.attributes?.screenBrightness
// F8: same capture/replay discipline as brightness above, and for the same reason.
// `view` is the Activity's single shared root ComposeView, and PlayerEventListener
// (ControlWhenPlayerIsActive.kt:150-165) owns this exact flag while media plays.
// Hard-setting `false` on dispose — instead of restoring what was here before this
// screen took it over — would clobber that ownership: navigating back from the QR
// screen while audio or video is still playing would let the screen sleep mid-playback.
val previousKeepScreenOn = view.keepScreenOn
window?.let {
it.attributes = it.attributes.apply { screenBrightness = 1f }
}
view.keepScreenOn = true
onDispose {
// Restore the captured value rather than calling a release helper: resetting to
// BRIGHTNESS_OVERRIDE_NONE unconditionally would clobber an override the user
// already had, e.g. one left by the fullscreen video controls.
window?.let { w ->
previousBrightness?.let { prev ->
w.attributes = w.attributes.apply { screenBrightness = prev }
}
}
view.keepScreenOn = previousKeepScreenOn
}
}
}
@@ -48,6 +48,7 @@ import com.google.zxing.qrcode.encoder.ByteMatrix
import com.google.zxing.qrcode.encoder.Encoder
import com.google.zxing.qrcode.encoder.QRCode
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import kotlin.math.min
/**
* The quiet zone around the code, in **modules** — the QR spec's minimum of 4.
@@ -59,6 +60,12 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
*/
const val QR_QUIET_ZONE_MODULES = 4f
/**
* Corner rounding on the finder patterns, as a fraction of one module. Small enough to stay well
* inside what a decoder tolerates, large enough to keep the code from looking like a 1998 barcode.
*/
const val FINDER_CORNER_RADIUS_MODULES = 0.22f
@Preview
@Composable
fun QrCodeDrawerPreview() {
@@ -90,7 +97,12 @@ fun QrCodeDrawer(
// (zone included) is exactly as wide as the canvas.
val rowHeight = size.height / (qrCode.matrix.height + QR_QUIET_ZONE_MODULES * 2f)
val columnWidth = size.width / (qrCode.matrix.width + QR_QUIET_ZONE_MODULES * 2f)
val radius = CornerRadius(20f)
// Scale the rounding with the module size. A fixed 20px radius is a gentle touch on
// a large code and a serious deformation on a small one: the finder patterns are what
// a decoder locates first, and rounding away a third of a module's worth of their
// corners is exactly the kind of damage that makes a code readable on screen and
// unreadable in a photo of that screen.
val radius = CornerRadius(min(columnWidth, rowHeight) * FINDER_CORNER_RADIUS_MODULES)
// Draw all of the finder patterns required by the QR spec. Calculate the ratio
// of the number of rows/columns to the width and height
@@ -20,64 +20,85 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import com.google.zxing.client.android.Intents
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.point_to_the_qr_code
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.QrCodeScannerDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.ScanOutcome
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.ScannedPayload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.classifyScannedPayload
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
/**
* Scans a QR code and navigates wherever it points.
*
* A payload we decode but cannot route no longer closes the scanner: it stays open and explains
* itself (see `ScanOutcomeSheet`), because "that is a Lightning invoice, not a profile" and "the
* camera never read anything" used to look identical from the outside.
*/
@Composable
fun NIP19QrCodeScanner(
accountViewModel: AccountViewModel,
onScan: (Route?) -> Unit,
) {
SimpleQrCodeScanner {
try {
if (it != null) {
onScan(uriToRoute(it, accountViewModel.account))
QrCodeScannerDialog(
onDismiss = { onScan(null) },
onScan = { contents ->
val route = routeFor(contents, accountViewModel)
if (route != null) {
onScan(route)
ScanOutcome.Handled
} else {
onScan(null)
ScanOutcome.NotSupported
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.e("NIP19 Scanner", "Error parsing $it", e)
// QR can be anything, do not throw errors.
onScan(null)
}
}
},
)
}
/**
* The route a scanned string leads to, or null when nothing here can open it.
*
* A bare hex pubkey gets re-encoded as an npub first. Plenty of web tools hand out a raw
* 64-character key with no bech32 wrapper, and treating that as unreadable has been a reported
* papercut since 2023 (issue #417).
*/
private fun routeFor(
contents: String,
accountViewModel: AccountViewModel,
): Route? =
try {
val payload = classifyScannedPayload(contents)
val uri = if (payload is ScannedPayload.HexPubKey) payload.npub else contents
uriToRoute(uri, accountViewModel.account)
} catch (e: Throwable) {
if (e is CancellationException) throw e
// The payload itself never reaches the log. A QR code is as likely to hold an nsec, a
// wallet-connect secret or a Cashu token as a profile link, and logcat is readable over
// adb and swept up by device bug reports — the same material ScannedPayload.containsSecret
// exists to keep off the screen two files away. The classification and the length say
// enough to debug a routing failure; classifying again here is wrapped because this is
// the branch for a payload that already made something throw.
val kind = runCatching { classifyScannedPayload(contents)::class.simpleName }.getOrNull() ?: "unclassifiable"
Log.e("NIP19 Scanner", "Could not route a scanned $kind payload of ${contents.length} chars", e)
// A QR code can hold anything at all. Never let one throw.
null
}
/**
* Scans a QR code and hands back whatever it says.
*
* For callers that do their own validation — a wallet-connect URI, a `bunker://` offer, a key on
* the login screen. `null` means the user backed out.
*/
@Composable
fun SimpleQrCodeScanner(onScan: (String?) -> Unit) {
val qrLauncher =
rememberLauncherForActivityResult(ScanContract()) {
if (it.contents != null) {
onScan(it.contents)
} else {
onScan(null)
}
}
val scanOptions =
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)
}
DisposableEffect(Unit) {
qrLauncher.launch(scanOptions)
onDispose {}
}
QrCodeScannerDialog(
onDismiss = { onScan(null) },
onScan = { contents ->
onScan(contents)
ScanOutcome.Handled
},
)
}
@@ -0,0 +1,176 @@
/*
* 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
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_no_code_in_image
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_try_again
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_unavailable
import com.vitorpamplona.amethyst.commons.resources.scan_qr
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.QrImageImport
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.ScanOutcomeSheet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.ScannedPayload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.ZxingCppBarcodeDecoder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.classifyScannedPayload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.copyToClipboard
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** What happened to the image the user shared in. */
private sealed interface ImageScanState {
data object Working : ImageScanState
/** Decoded, but nothing here can open it — the outcome sheet explains which. */
data class Unsupported(
val payload: ScannedPayload,
) : ImageScanState
/** No QR code in the picture at all, or the decoder could not start. */
data class Failed(
val message: String,
) : ImageScanState
}
/**
* Reads a QR code out of a picture the user shared into Amethyst, and goes where it points.
*
* The share sheet is how most QR codes actually arrive — a screenshot, or a photo someone sent in
* a chat. Before this the only way to use one was to put it on a second screen and photograph it.
*
* Failure is explicit here for the same reason it is in the live scanner: "that picture has no QR
* code in it" and "that code is something Amethyst can't open" are different problems, and
* collapsing them into a silent bounce is what made the old reader feel broken.
*/
@Composable
fun ScanQrImageScreen(
uri: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val decoder = remember { runCatching { ZxingCppBarcodeDecoder() }.getOrNull() }
var state by remember { mutableStateOf<ImageScanState>(ImageScanState.Working) }
val noCodeFound = stringRes(Res.string.qr_scanner_no_code_in_image)
val decoderUnavailable = stringRes(Res.string.qr_scanner_unavailable)
val context = LocalContext.current
LaunchedEffect(uri, decoder) {
if (decoder == null) {
state = ImageScanState.Failed(decoderUnavailable)
return@LaunchedEffect
}
val text =
withContext(Dispatchers.IO) {
QrImageImport.decode(context, uri.toUri(), decoder).firstOrNull()?.text
}
if (text == null) {
state = ImageScanState.Failed(noCodeFound)
return@LaunchedEffect
}
val payload = classifyScannedPayload(text)
// A bare hex pubkey is re-encoded first, the same as in the live scanner.
val routable = if (payload is ScannedPayload.HexPubKey) payload.npub else text
val route = runCatching { uriToRoute(routable, accountViewModel.account) }.getOrNull()
if (route != null) {
nav.newStack(route)
} else {
state = ImageScanState.Unsupported(payload)
}
}
Scaffold(topBar = { TopBarWithBackButton(stringRes(Res.string.scan_qr), nav) }) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (val current = state) {
is ImageScanState.Working -> CircularProgressIndicator()
is ImageScanState.Failed -> {
Text(
text = current.message,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
)
// The camera is the obvious next move when the picture turned out to hold
// nothing, so offer it rather than leaving a dead end.
Button(onClick = { nav.newStack(Route.QRDisplay(accountViewModel.userProfile().pubkeyHex, startScanning = true)) }) {
Text(stringRes(Res.string.scan_qr))
}
TextButton(onClick = { nav.popBack() }) {
Text(stringRes(Res.string.qr_scanner_try_again))
}
}
is ImageScanState.Unsupported -> Unit
}
}
}
(state as? ImageScanState.Unsupported)?.let { unsupported ->
ScanOutcomeSheet(
payload = unsupported.payload,
onDismiss = { nav.popBack() },
onOpenLink = { url ->
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
nav.popBack()
},
onCopy = { text ->
copyToClipboard(context, text)
nav.popBack()
},
)
}
}
@@ -122,6 +122,7 @@ fun ShowQRScreen(
pubkey: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
startScanning: Boolean = false,
) {
LoadUser(pubkey, accountViewModel) { user ->
if (user != null) {
@@ -129,6 +130,7 @@ fun ShowQRScreen(
user = user,
accountViewModel = accountViewModel,
nav = nav,
startScanning = startScanning,
)
}
}
@@ -140,6 +142,7 @@ fun ShowQRScreen(
user: User,
accountViewModel: AccountViewModel,
nav: INav,
startScanning: Boolean = false,
) {
Scaffold(
topBar = {
@@ -162,7 +165,7 @@ fun ShowQRScreen(
verticalArrangement = Arrangement.SpaceAround,
horizontalAlignment = Alignment.CenterHorizontally,
) {
ShowQRBody(user, accountViewModel, nav)
ShowQRBody(user, accountViewModel, nav, startScanning)
}
}
}
@@ -172,8 +175,11 @@ fun ShowQRBody(
user: User,
accountViewModel: AccountViewModel,
nav: INav,
startScanning: Boolean = false,
) {
var presenting by remember { mutableStateOf(true) }
// The launcher shortcut lands here already scanning; everything else starts on the user's own
// code with a Scan button.
var presenting by remember { mutableStateOf(!startScanning) }
if (presenting) {
PresentQR(user, accountViewModel) {
presenting = false
@@ -195,6 +201,10 @@ fun PresentQR(
accountViewModel: AccountViewModel,
switchToScan: () -> Unit,
) {
// The other phone's camera needs contrast, and this screen was the one place we showed a QR
// code without asking for it (ShareNoteAsQrScreen has done so since it shipped).
KeepScreenBrightAndAwake()
RenderName(user, accountViewModel)
Row(
@@ -0,0 +1,155 @@
/*
* 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.Rect
import androidx.camera.core.ImageProxy
import com.vitorpamplona.quartz.utils.Log
import zxingcpp.BarcodeReader
/**
* How hard to work on one image.
*
* The live camera runs [Fast] on most frames and [Thorough] on every Nth, so a difficult code
* still gets the expensive treatment a few times a second without dropping the frame rate for
* the easy ones. Imported stills always get [Thorough] — there is only one image and the user is
* waiting on it.
*/
enum class DecodeEffort {
Fast,
Thorough,
}
/**
* Decodes QR codes out of camera frames and still images.
*
* An interface, not a class, because the engine is a choice we want to be able to re-make: the
* camera plumbing, the overlay and every test above this line are engine-agnostic.
*/
interface BarcodeDecoder {
/** Decodes straight from a CameraX analysis frame. Called on the analysis executor. */
fun decode(
image: ImageProxy,
effort: DecodeEffort,
): List<ScanResult>
/** Decodes an imported still (gallery, clipboard, share). Called off the main thread. */
fun decode(
bitmap: Bitmap,
effort: DecodeEffort = DecodeEffort.Thorough,
): List<ScanResult>
}
/**
* [BarcodeDecoder] on zxing-cpp (Apache-2.0).
*
* Every option here maps to a failure the previous zxing-android-embedded scanner had:
*
* - `tryInvert` replaces the old `MIXED_SCAN`, which inverted *alternate frames* and so threw
* away half of all decode attempts on ordinary dark-on-light codes. zxing-cpp does the
* inverted pass as a fallback inside one call, so nothing is wasted.
* - `tryRotate` picks up codes held sideways.
* - `tryDownscale` finds codes that fill most of the frame, which a fixed-scale detector misses.
* - `tryHarder`/`tryDenoise` (Thorough only) are what actually rescue blurred, creased,
* low-contrast and photographed-off-a-screen codes.
* - `maxNumberOfSymbols = MAX_SYMBOLS` so several codes in frame become a choice for the user
* rather than a coin flip.
*/
class ZxingCppBarcodeDecoder : BarcodeDecoder {
private val fast = BarcodeReader(options(tryHarder = false))
private val thorough = BarcodeReader(options(tryHarder = true))
private fun reader(effort: DecodeEffort) = if (effort == DecodeEffort.Fast) fast else thorough
override fun decode(
image: ImageProxy,
effort: DecodeEffort,
): List<ScanResult> =
try {
reader(effort).read(image).toScanResults()
} catch (e: IllegalStateException) {
// read() rejects any format that is not YUV. We always request YUV_420_888, so this
// means the device handed us something else; log once per frame rather than crash.
Log.w("QrScanner") { "Unsupported analysis image format ${image.format}: ${e.message}" }
emptyList()
}
override fun decode(
bitmap: Bitmap,
effort: DecodeEffort,
): List<ScanResult> = reader(effort).read(bitmap, Rect(0, 0, bitmap.width, bitmap.height)).toScanResults()
private fun List<BarcodeReader.Result>.toScanResults(): List<ScanResult> =
mapNotNull { result ->
val text = result.text
if (result.error != null || text.isNullOrEmpty()) return@mapNotNull null
ScanResult(
text = text,
bounds =
result.position.let {
ScanBounds(
topLeft = ScanPoint(it.topLeft.x.toFloat(), it.topLeft.y.toFloat()),
topRight = ScanPoint(it.topRight.x.toFloat(), it.topRight.y.toFloat()),
bottomRight = ScanPoint(it.bottomRight.x.toFloat(), it.bottomRight.y.toFloat()),
bottomLeft = ScanPoint(it.bottomLeft.x.toFloat(), it.bottomLeft.y.toFloat()),
)
},
sequenceId = result.sequenceId,
sequenceIndex = result.sequenceIndex,
sequenceSize = result.sequenceSize,
)
}
companion object {
/**
* Enough to disambiguate a poster with a few codes on it without letting a page of
* barcodes turn every frame into a long detection run.
*/
const val MAX_SYMBOLS = 5
/**
* Only the square formats. Nostr uses plain QR, but Micro and rMQR cost nothing extra to
* accept and some hardware wallets and printed tags use them. Linear barcodes stay off:
* they have no meaning here and each extra family slows every frame down.
*/
private val FORMATS =
setOf(
BarcodeReader.Format.QR_CODE,
BarcodeReader.Format.MICRO_QR_CODE,
BarcodeReader.Format.RMQR_CODE,
)
private fun options(tryHarder: Boolean) =
BarcodeReader.Options(
formats = FORMATS,
tryHarder = tryHarder,
tryRotate = true,
tryInvert = true,
tryDownscale = true,
tryDenoise = tryHarder,
binarizer = BarcodeReader.Binarizer.LOCAL_AVERAGE,
maxNumberOfSymbols = MAX_SYMBOLS,
textMode = BarcodeReader.TextMode.PLAIN,
)
}
}
@@ -0,0 +1,136 @@
/*
* 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 androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.vitorpamplona.quartz.utils.Log
import kotlin.math.max
/** One analysed frame: what was decoded, how big the analysed image was, and how dark it is. */
data class FrameScan(
val results: List<ScanResult>,
val frame: ScanFrame,
/** Mean luminance over a sparse sample of the Y plane, 0f (black) to 1f (white). */
val brightness: Float,
)
/**
* Decodes every camera frame and reports what it found.
*
* Two things happen per frame beyond the decode itself:
*
* 1. **Effort alternates.** Most frames get a fast pass; every [THOROUGH_EVERY]th gets
* `tryHarder` + `tryDenoise`. A stubborn code therefore still gets several expensive attempts
* a second, without the frame rate collapsing for codes that were never difficult.
* 2. **Brightness is measured.** A sparse sample of the Y plane costs almost nothing and is what
* lets the UI offer the torch exactly when the scene is too dark, rather than parking a
* permanent button in the corner or firing the light at people unprompted.
*/
class QrFrameAnalyzer(
private val decoder: BarcodeDecoder,
private val onFrame: (FrameScan) -> Unit,
) : ImageAnalysis.Analyzer {
private var frameCount = 0L
override fun analyze(image: ImageProxy) {
image.use {
val effort =
if (frameCount++ % THOROUGH_EVERY == 0L) DecodeEffort.Thorough else DecodeEffort.Fast
val brightness = meanLuminance(image)
val results =
try {
decoder.decode(image, effort)
} catch (e: Exception) {
// A frame we cannot read is not worth killing the camera over.
Log.w("QrScanner") { "Decode failed on one frame: ${e.message}" }
emptyList()
}
onFrame(FrameScan(results, rotatedFrameSize(image), brightness))
}
}
/**
* The size of the image the decoder actually saw.
*
* zxing-cpp is handed the crop rect and the rotation, and reports positions inside that
* cropped, rotated space — so a quarter-turn swaps width and height. The overlay maps
* [ScanBounds] onto the preview with this, so getting it wrong draws the highlight in the
* wrong place.
*/
private fun rotatedFrameSize(image: ImageProxy): ScanFrame {
val crop = image.cropRect
val quarterTurned = image.imageInfo.rotationDegrees % 180 != 0
return if (quarterTurned) {
ScanFrame(crop.height(), crop.width())
} else {
ScanFrame(crop.width(), crop.height())
}
}
/**
* Mean luminance over a grid of at most [LUMA_SAMPLES_PER_AXIS]² pixels.
*
* Uses absolute [java.nio.ByteBuffer.get] so it never disturbs the buffer position the
* decoder is about to read from.
*/
private fun meanLuminance(image: ImageProxy): Float {
val plane = image.planes.firstOrNull() ?: return 1f
val buffer = plane.buffer
val rowStride = plane.rowStride
val crop = image.cropRect
val stepX = max(1, crop.width() / LUMA_SAMPLES_PER_AXIS)
val stepY = max(1, crop.height() / LUMA_SAMPLES_PER_AXIS)
var sum = 0L
var count = 0
var y = crop.top
while (y < crop.bottom) {
val row = y * rowStride
var x = crop.left
while (x < crop.right) {
val index = row + x
if (index in 0 until buffer.limit()) {
sum += buffer.get(index).toInt() and 0xFF
count++
}
x += stepX
}
y += stepY
}
return if (count == 0) 1f else sum.toFloat() / count / 255f
}
companion object {
/**
* At ~30fps this is roughly six thorough passes a second — enough that a hard code
* resolves in well under a second, few enough that the analysis thread keeps up.
*/
const val THOROUGH_EVERY = 5L
private const val LUMA_SAMPLES_PER_AXIS = 24
}
}
@@ -0,0 +1,202 @@
/*
* 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.content.ClipboardManager
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.core.graphics.scale
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import kotlin.math.max
/**
* Decoding a QR code out of an image the user already has.
*
* This is the single biggest gap the camera-only scanner left: most QR codes people need to scan
* in a Nostr client arrive as a screenshot, a photo in a chat, or an image saved from a website.
* Before this, the only way to use one was to display it on a second screen and photograph it.
*/
object QrImageImport {
/**
* Longest edge we downscale a picked image to for the first attempt.
*
* Detection does not improve above this, and full-resolution phone photos are 50+ megapixels
* of mostly-wall that make a thorough pass take seconds.
*/
private const val FIRST_PASS_MAX_EDGE = 2_000
/** Below this, a code is likely too few pixels per module; the upscale pass is worth trying. */
private const val SMALL_IMAGE_EDGE = 600
/** Decodes every QR code in the image at [uri], hardest-effort, with a retry ladder. */
suspend fun decode(
context: Context,
uri: Uri,
decoder: BarcodeDecoder,
): List<ScanResult> =
withContext(Dispatchers.IO) {
val bounds = readBounds(context, uri) ?: return@withContext emptyList()
val longestEdge = max(bounds.outWidth, bounds.outHeight)
if (longestEdge <= 0) return@withContext emptyList()
// Three passes, cheapest first. A code that a downscaled pass misses because its
// modules blurred together often survives at full resolution, and a code in a small
// thumbnail often needs *more* pixels per module than it shipped with.
val sampleSizes =
buildList {
add(sampleSizeFor(longestEdge, FIRST_PASS_MAX_EDGE))
if (sampleSizeFor(longestEdge, FIRST_PASS_MAX_EDGE) != 1) add(1)
}
// Checked between passes because a pass itself is one long blocking call into JNI
// and cannot be interrupted. The expensive pass is the full-resolution retry: a
// modern phone photo is 50-108 MP, so closing the scanner while one is running
// otherwise leaves several hundred megabytes and a thorough decode grinding away on
// an IO thread for a result nobody is waiting for any more.
for (sampleSize in sampleSizes) {
ensureActive()
val found = decodeAt(context, uri, sampleSize, upscale = false, decoder)
if (found.isNotEmpty()) return@withContext found
}
if (longestEdge <= SMALL_IMAGE_EDGE) {
ensureActive()
val found = decodeAt(context, uri, sampleSize = 1, upscale = true, decoder)
if (found.isNotEmpty()) return@withContext found
}
emptyList()
}
/** Text sitting on the clipboard, or null when there is none. */
fun clipboardText(context: Context): String? {
val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return null
val clip = clipboard.primaryClip ?: return null
if (clip.itemCount == 0) return null
return clip
.getItemAt(0)
?.coerceToText(context)
?.toString()
?.trim()
?.takeIf { it.isNotEmpty() }
}
/**
* An image sitting on the clipboard, or null when there is none.
*
* The MIME check matters: a copied link is a clip with a uri too, and without it every such
* clip went to the bitmap decoder and the clipboard *text* was never reachable — Paste could
* not paste an npub, which is most of what it is for.
*/
fun clipboardImage(context: Context): Uri? {
val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return null
val clip = clipboard.primaryClip ?: return null
if (clip.itemCount == 0) return null
val uri = clip.getItemAt(0)?.uri ?: return null
val declared = (0 until clip.description.mimeTypeCount).map { clip.description.getMimeType(it) }
val looksLikeImage =
declared.any { it.startsWith("image/") } ||
context.contentResolver.getType(uri)?.startsWith("image/") == true
return uri.takeIf { looksLikeImage }
}
private fun decodeAt(
context: Context,
uri: Uri,
sampleSize: Int,
upscale: Boolean,
decoder: BarcodeDecoder,
): List<ScanResult> {
val original = loadBitmap(context, uri, sampleSize) ?: return emptyList()
var upscaled: Bitmap? = null
return try {
if (upscale) upscaled = original.scale(original.width * 2, original.height * 2)
decoder.decode(upscaled ?: original, DecodeEffort.Thorough)
} catch (e: OutOfMemoryError) {
// Not an Exception, so a plain `catch (e: Exception)` misses it. The retry ladder
// deliberately re-decodes at inSampleSize = 1, and a modern phone camera hands us
// 50-108 MP: ~400 MB as ARGB_8888, which is well past the heap on most devices.
// Failing to read a picture must never take the app down with it.
Log.w("QrScanner", "Ran out of memory decoding a picked image", e)
emptyList()
} catch (e: Exception) {
Log.w("QrScanner", "Could not decode picked image", e)
emptyList()
} finally {
upscaled?.recycle()
original.recycle()
}
}
private fun readBounds(
context: Context,
uri: Uri,
): BitmapFactory.Options? =
try {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) }
options
} catch (e: Exception) {
Log.w("QrScanner", "Could not read image bounds", e)
null
}
/**
* Always an ARGB_8888 software bitmap: zxing-cpp reads the pixels over JNI, and a
* hardware-backed bitmap has no pixels to read.
*/
private fun loadBitmap(
context: Context,
uri: Uri,
sampleSize: Int,
): Bitmap? =
try {
val options =
BitmapFactory.Options().apply {
inSampleSize = sampleSize
inPreferredConfig = Bitmap.Config.ARGB_8888
}
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) }
} catch (e: OutOfMemoryError) {
Log.w("QrScanner", "Ran out of memory loading a picked image", e)
null
} catch (e: Exception) {
Log.w("QrScanner", "Could not load picked image", e)
null
}
/** The power-of-two `inSampleSize` that brings [longestEdge] to at most [target]. */
private fun sampleSizeFor(
longestEdge: Int,
target: Int,
): Int {
var sample = 1
while (longestEdge / sample > target) sample *= 2
return sample
}
}
@@ -0,0 +1,613 @@
/*
* 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.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
import android.util.Size
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.core.UseCaseGroup
import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.lifecycle.awaitInstance
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.close
import com.vitorpamplona.amethyst.commons.resources.point_to_the_qr_code
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_camera_blocked
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_camera_rationale
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_clipboard_empty
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_grant_camera
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_no_code_in_image
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_open_settings
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_unavailable
import com.vitorpamplona.amethyst.ui.call.openAppSettings
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.math.min
/** What the caller did with a decoded payload — and therefore what the scanner does next. */
enum class ScanOutcome {
/** The caller acted on it. Close the scanner. */
Handled,
/** Decoded fine, but this screen has nothing to do with it. Explain, and keep scanning. */
NotSupported,
}
/**
* Full-screen QR scanner.
*
* A dialog rather than a navigation route on purpose: one of its callers is the logged-out login
* field, which lives outside the navigation graph entirely.
*/
@Composable
fun QrCodeScannerDialog(
onDismiss: () -> Unit,
onScan: (String) -> ScanOutcome,
) {
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false,
),
) {
SetDialogToEdgeToEdge()
Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
QrScannerScreen(onDismiss = onDismiss, onScan = onScan)
}
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun QrScannerScreen(
onDismiss: () -> Unit,
onScan: (String) -> ScanOutcome,
) {
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
if (cameraPermission.status.isGranted) {
QrCameraScanner(onDismiss = onDismiss, onScan = onScan)
} else {
CameraPermissionGate(permission = cameraPermission, onDismiss = onDismiss)
}
}
/**
* Asks for the camera, and explains itself when refused.
*
* The old scanner simply closed its activity when the permission was denied, which from the
* user's side is a button that does nothing.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun CameraPermissionGate(
permission: PermissionState,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var asked by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
asked = true
permission.launchPermissionRequest()
}
// `shouldShowRationale` is false both before the first ask and after a permanent denial, so
// the two are only distinguishable once we know we have asked.
val blocked = asked && !permission.status.shouldShowRationale
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text =
if (blocked) {
stringRes(Res.string.qr_scanner_camera_blocked)
} else {
stringRes(Res.string.qr_scanner_camera_rationale)
},
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
)
if (blocked) {
Button(onClick = { openAppSettings(context) }) {
Text(stringRes(Res.string.qr_scanner_open_settings))
}
} else {
Button(onClick = { permission.launchPermissionRequest() }) {
Text(stringRes(Res.string.qr_scanner_grant_camera))
}
}
// Closes the scanner, so it says so: with the camera refused there is nothing to scan again.
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.close), color = Color.White)
}
}
}
@Composable
private fun QrCameraScanner(
onDismiss: () -> Unit,
onScan: (String) -> ScanOutcome,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val haptic = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val state = remember { QrScannerState() }
val decoder = remember { runCatching { ZxingCppBarcodeDecoder() }.getOrNull() }
val currentOnScan by rememberUpdatedState(onScan)
val currentOnDismiss by rememberUpdatedState(onDismiss)
val noCodeInImage = stringRes(Res.string.qr_scanner_no_code_in_image)
val clipboardEmpty = stringRes(Res.string.qr_scanner_clipboard_empty)
val decoderUnavailable = stringRes(Res.string.qr_scanner_unavailable)
// One shared accept path: camera frames, a tapped candidate and an imported image all land
// here, so the haptic, the dedupe and the "we can't open this" branch behave identically
// however the payload arrived.
val submit: (String) -> Unit = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
when (currentOnScan(text)) {
ScanOutcome.Handled -> currentOnDismiss()
ScanOutcome.NotSupported -> state.onRejected(classifyScannedPayload(text))
}
}
val analysisExecutor = remember { Executors.newSingleThreadExecutor() }
// CONFLATED: the analysis thread outruns the UI, and an old frame is worthless — the only
// one worth acting on is the newest.
val frames = remember { Channel<FrameScan>(Channel.CONFLATED) }
val previewView =
remember {
PreviewView(context).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
}
var camera by remember { mutableStateOf<Camera?>(null) }
var provider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
var viewSize by remember { mutableStateOf(IntSize.Zero) }
var focusRing by remember { mutableStateOf<Offset?>(null) }
DisposableEffect(Unit) {
onDispose {
analysisExecutor.shutdown()
frames.close()
runCatching { provider?.unbindAll() }
}
}
LaunchedEffect(decoder) {
if (decoder == null) state.notice = decoderUnavailable
}
// Bind once the preview has a size: ViewPort needs a laid-out view, and binding both use
// cases under one viewport is what makes the overlay's coordinate mapping exact.
LaunchedEffect(decoder, viewSize) {
if (decoder == null || viewSize == IntSize.Zero) return@LaunchedEffect
val cameraProvider =
try {
ProcessCameraProvider.awaitInstance(context)
} catch (e: Exception) {
Log.w("QrScanner", "Camera provider unavailable", e)
state.notice = decoderUnavailable
return@LaunchedEffect
}
provider = cameraProvider
val preview = Preview.Builder().build().apply { setSurfaceProvider(previewView.surfaceProvider) }
val analysis =
ImageAnalysis
.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
.setResolutionSelector(ANALYSIS_RESOLUTION)
.build()
.apply {
setAnalyzer(analysisExecutor, QrFrameAnalyzer(decoder) { frames.trySend(it) })
}
val group =
UseCaseGroup
.Builder()
.addUseCase(preview)
.addUseCase(analysis)
.apply { previewView.viewPort?.let { setViewPort(it) } }
.build()
try {
cameraProvider.unbindAll()
camera =
cameraProvider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, group).also {
state.torchAvailable = it.cameraInfo.hasFlashUnit()
state.maxZoomRatio = it.cameraInfo.zoomState.value
?.maxZoomRatio ?: 1f
}
} catch (e: Exception) {
Log.w("QrScanner", "Could not bind the camera", e)
state.notice = decoderUnavailable
}
}
LaunchedEffect(Unit) {
for (scan in frames) {
state.onFrame(scan, SystemClock.elapsedRealtime())?.let(submit)
}
}
// One writer each for torch and zoom, driven off state rather than from the gesture handlers,
// so the auto-zoom sweep and a pinch cannot fight over the camera control.
LaunchedEffect(camera) {
val control = camera?.cameraControl ?: return@LaunchedEffect
snapshotFlow { state.torchOn }.collect { runCatching { control.enableTorch(it) } }
}
LaunchedEffect(camera) {
val control = camera?.cameraControl ?: return@LaunchedEffect
snapshotFlow { state.zoomRatio }.collect { runCatching { control.setZoomRatio(it) } }
}
AutoZoomSweep(state = state, enabled = camera != null)
LaunchedEffect(state.notice) {
if (state.notice != null) {
delay(NOTICE_DURATION_MS)
state.notice = null
}
}
LaunchedEffect(focusRing) {
if (focusRing != null) {
delay(FOCUS_RING_DURATION_MS)
focusRing = null
}
}
val pickImage =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri == null || decoder == null) return@rememberLauncherForActivityResult
scope.launch {
val found = QrImageImport.decode(context, uri, decoder).firstOrNull()?.text
if (found == null) state.notice = noCodeInImage else submit(found)
}
}
Box(
modifier =
Modifier
.fillMaxSize()
.onSizeChanged { viewSize = it }
.pointerInput(Unit) {
detectTransformGestures { _, _, zoom, _ ->
if (zoom != 1f) {
state.onPinch()
state.zoomRatio = (state.zoomRatio * zoom).coerceIn(1f, maxOf(1f, state.maxZoomRatio))
}
}
}.pointerInput(Unit) {
detectTapGestures { tap ->
val picked = pickCandidateAt(tap, state, viewSize)
if (picked != null) {
state.onCandidateTapped(picked, SystemClock.elapsedRealtime())?.let(submit)
} else {
focusRing = tap
focusAt(previewView, camera, tap)
}
}
},
) {
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
ScanOverlayCanvas(state = state, viewSize = viewSize, focusRing = focusRing)
Text(
text = stringRes(Res.string.point_to_the_qr_code),
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.TopCenter).padding(top = 96.dp).fillMaxWidth(0.8f),
)
QrScannerControls(
state = state,
onClose = onDismiss,
onToggleTorch = { state.torchOn = !state.torchOn },
onPickImage = { pickImage.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
onPaste = {
pasteFromClipboard(
context = context,
decoder = decoder,
onText = submit,
onImage = { uri ->
if (decoder != null) {
scope.launch {
val found = QrImageImport.decode(context, uri, decoder).firstOrNull()?.text
if (found == null) state.notice = noCodeInImage else submit(found)
}
}
},
onEmpty = { state.notice = clipboardEmpty },
)
},
)
}
state.rejected?.let { payload ->
ScanOutcomeSheet(
payload = payload,
onDismiss = { state.dismissRejection(SystemClock.elapsedRealtime()) },
onOpenLink = { url ->
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
state.dismissRejection(SystemClock.elapsedRealtime())
currentOnDismiss()
},
onCopy = { text ->
copyToClipboard(context, text)
state.dismissRejection(SystemClock.elapsedRealtime())
},
)
}
}
/** Draws the aiming brackets, any codes we can see, and the tap-to-focus ring. */
@Composable
private fun ScanOverlayCanvas(
state: QrScannerState,
viewSize: IntSize,
focusRing: Offset?,
) {
val highlight = MaterialTheme.colorScheme.primary
Canvas(modifier = Modifier.fillMaxSize()) {
val mapping = ScanViewMapping.of(state.frame, viewSize)
if (state.candidates.isEmpty()) {
drawViewfinderBrackets(Color.White.copy(alpha = 0.65f))
} else if (mapping != null) {
state.candidates.forEach { candidate ->
candidate.bounds?.let {
drawPath(
path = it.toViewPath(mapping),
color = highlight,
style = Stroke(width = 4.dp.toPx()),
)
}
}
}
focusRing?.let {
drawCircle(
color = Color.White.copy(alpha = 0.8f),
radius = 28.dp.toPx(),
center = it,
style = Stroke(width = 2.dp.toPx()),
)
}
}
}
/**
* Sweeps the zoom while nothing is decoding.
*
* A code too small in frame to resolve is the single most common reason a scan fails, and no
* amount of decoder tuning fixes it — there are not enough pixels per module to read. Rather than
* leave the user to work that out and walk closer, the camera pushes in and back out on its own.
* It stops for good once the user pinches: they have taken over.
*/
@Composable
private fun AutoZoomSweep(
state: QrScannerState,
enabled: Boolean,
) {
LaunchedEffect(enabled, state.autoZoomEnabled) {
if (!enabled || !state.autoZoomEnabled) return@LaunchedEffect
var sweep = 0f
while (isActive) {
delay(AUTO_ZOOM_TICK_MS)
if (state.msSinceLastDetection < QrScannerState.AUTO_ZOOM_AFTER_MS) {
if (sweep != 0f) {
sweep = 0f
state.zoomRatio = 1f
}
continue
}
val ceiling = min(state.maxZoomRatio, QrScannerState.AUTO_ZOOM_MAX)
if (ceiling <= 1.01f) continue
sweep = (sweep + AUTO_ZOOM_TICK_MS.toFloat() / AUTO_ZOOM_PERIOD_MS) % 1f
val triangle = if (sweep < 0.5f) sweep * 2f else (1f - sweep) * 2f
state.zoomRatio = 1f + triangle * (ceiling - 1f)
}
}
}
/** The visible code nearest the tap, or null when the tap was not on one. */
private fun pickCandidateAt(
tap: Offset,
state: QrScannerState,
viewSize: IntSize,
): ScanResult? {
if (state.candidates.size <= 1) return null
val mapping = ScanViewMapping.of(state.frame, viewSize) ?: return null
return state.candidates
.mapNotNull { candidate ->
val bounds = candidate.bounds ?: return@mapNotNull null
val center = bounds.centerInView(mapping)
// In view pixels, to match `distance`. bounds.longestSide is image-space.
val radius = maxOf(bounds.longestSideInView(mapping), MIN_TAP_RADIUS_PX)
val distance = (center - tap).getDistance()
if (distance <= radius) candidate to distance else null
}.minByOrNull { it.second }
?.first
}
private fun focusAt(
previewView: PreviewView,
camera: Camera?,
tap: Offset,
) {
val control = camera?.cameraControl ?: return
runCatching {
val point = previewView.meteringPointFactory.createPoint(tap.x, tap.y)
control.startFocusAndMetering(
FocusMeteringAction
.Builder(point, FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE)
.setAutoCancelDuration(FOCUS_AUTO_CANCEL_SECONDS, TimeUnit.SECONDS)
.build(),
)
}
}
private fun pasteFromClipboard(
context: Context,
decoder: BarcodeDecoder?,
onText: (String) -> Unit,
onImage: (Uri) -> Unit,
onEmpty: () -> Unit,
) {
val image = QrImageImport.clipboardImage(context)
if (image != null && decoder != null) {
onImage(image)
return
}
val text = QrImageImport.clipboardText(context)
if (!text.isNullOrBlank()) onText(text) else onEmpty()
}
/** Shared with the shared-image scan screen, which offers the same action on the same sheet. */
internal fun copyToClipboard(
context: Context,
text: String,
) {
runCatching {
val clipboard = context.getSystemService(ClipboardManager::class.java)
clipboard?.setPrimaryClip(ClipData.newPlainText("", text))
}
}
/**
* 1280x720 is the sweet spot: plenty of pixels per module for a code at arm's length, while
* staying inside what every device can sustain at full frame rate through an analysis pipeline.
* Falling back higher before lower keeps detail on devices that cannot produce exactly this.
*/
private val ANALYSIS_RESOLUTION =
ResolutionSelector
.Builder()
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
.setResolutionStrategy(
ResolutionStrategy(Size(1280, 720), ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER),
).build()
private const val AUTO_ZOOM_TICK_MS = 100L
private const val AUTO_ZOOM_PERIOD_MS = 3_000f
private const val NOTICE_DURATION_MS = 3_000L
private const val FOCUS_RING_DURATION_MS = 800L
private const val FOCUS_AUTO_CANCEL_SECONDS = 4L
private const val MIN_TAP_RADIUS_PX = 120f
@@ -0,0 +1,324 @@
/*
* 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 androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.close
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_dark_hint
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_paste
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_pick_one
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_scan_image
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_sequence_progress
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_torch_off
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_torch_on
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_zoom_reset
import com.vitorpamplona.amethyst.ui.stringRes
import kotlin.math.max
/**
* Maps the decoder's image space onto the preview.
*
* The preview is FILL_CENTER and the analysis frame shares its field of view — both use cases are
* bound under one [androidx.camera.core.ViewPort] — so one uniform scale plus a centring offset
* is exact. [max] rather than `min` because FILL_CENTER overfills and crops.
*/
class ScanViewMapping private constructor(
private val scale: Float,
private val dx: Float,
private val dy: Float,
) {
fun map(point: ScanPoint) = Offset(point.x * scale + dx, point.y * scale + dy)
companion object {
fun of(
frame: ScanFrame,
viewSize: IntSize,
): ScanViewMapping? {
if (frame.width <= 0 || frame.height <= 0 || viewSize.width <= 0 || viewSize.height <= 0) return null
val scale =
max(
viewSize.width.toFloat() / frame.width,
viewSize.height.toFloat() / frame.height,
)
return ScanViewMapping(
scale = scale,
dx = (viewSize.width - frame.width * scale) / 2f,
dy = (viewSize.height - frame.height * scale) / 2f,
)
}
}
}
fun ScanBounds.toViewPath(mapping: ScanViewMapping): Path =
Path().apply {
val start = mapping.map(topLeft)
moveTo(start.x, start.y)
mapping.map(topRight).let { lineTo(it.x, it.y) }
mapping.map(bottomRight).let { lineTo(it.x, it.y) }
mapping.map(bottomLeft).let { lineTo(it.x, it.y) }
close()
}
fun ScanBounds.centerInView(mapping: ScanViewMapping): Offset = mapping.map(ScanPoint(centerX, centerY))
/**
* [longestSide] expressed in view pixels.
*
* Needed because [longestSide] is measured in the decoder's image space: comparing it directly
* against a distance in view space silently shrinks or grows the hit area by the preview's scale
* factor, which on a 1280x720 analysis frame shown on a 1080p-wide screen is off by nearly 2x.
*/
fun ScanBounds.longestSideInView(mapping: ScanViewMapping): Float {
val a = mapping.map(topLeft)
val b = mapping.map(topRight)
val c = mapping.map(bottomRight)
val d = mapping.map(bottomLeft)
return maxOf((a - b).getDistance(), (b - c).getDistance(), (c - d).getDistance(), (d - a).getDistance())
}
/** Four corner brackets marking where to aim. Purely decorative — we decode the whole frame. */
fun DrawScope.drawViewfinderBrackets(color: Color) {
val side = minOf(size.width, size.height) * 0.68f
val left = (size.width - side) / 2f
val top = (size.height - side) / 2f
val arm = side * 0.12f
val stroke = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round)
fun bracket(
x: Float,
y: Float,
dx: Float,
dy: Float,
) {
drawPath(
Path().apply {
moveTo(x, y + dy * arm)
lineTo(x, y)
lineTo(x + dx * arm, y)
},
color = color,
style = stroke,
)
}
bracket(left, top, 1f, 1f)
bracket(left + side, top, -1f, 1f)
bracket(left, top + side, 1f, -1f)
bracket(left + side, top + side, -1f, -1f)
}
/**
* The chrome over the camera feed: close, torch, import, hints and progress.
*
* Deliberately a slot-free, single-purpose component — there is exactly one scanner screen, and
* pulling these five controls out into parameters would be ceremony without a second caller.
*/
@Composable
fun QrScannerControls(
state: QrScannerState,
onClose: () -> Unit,
onToggleTorch: () -> Unit,
onPickImage: () -> Unit,
onPaste: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier.fillMaxSize()) {
IconButton(
onClick = onClose,
modifier = Modifier.align(Alignment.TopStart).statusBarsPadding().padding(8.dp),
) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(Res.string.close),
tint = Color.White,
modifier = Modifier.size(28.dp),
)
}
if (!state.autoZoomEnabled && state.zoomRatio > 1.05f) {
ZoomChip(
zoomRatio = state.zoomRatio,
onReset = { state.resetZoom() },
modifier = Modifier.align(Alignment.TopEnd).statusBarsPadding().padding(12.dp),
)
}
HintStack(
state = state,
modifier =
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 96.dp, start = 24.dp, end = 24.dp),
)
Row(
modifier =
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.fillMaxWidth()
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally),
) {
if (state.torchAvailable) {
OverlayButton(
symbol = if (state.torchOn) MaterialSymbols.FlashlightOff else MaterialSymbols.FlashlightOn,
description =
if (state.torchOn) {
stringRes(Res.string.qr_scanner_torch_off)
} else {
stringRes(Res.string.qr_scanner_torch_on)
},
highlighted = state.torchOn,
onClick = onToggleTorch,
)
}
OverlayButton(
symbol = MaterialSymbols.PhotoLibrary,
description = stringRes(Res.string.qr_scanner_scan_image),
onClick = onPickImage,
)
OverlayButton(
symbol = MaterialSymbols.ContentPaste,
description = stringRes(Res.string.qr_scanner_paste),
onClick = onPaste,
)
}
}
}
/**
* The one line of text under the viewfinder.
*
* Only ever shows one message, most urgent first: a notice the user asked for ("no code in that
* picture") beats sequence progress, which beats "pick one of these", which beats the dark hint.
*/
@Composable
private fun HintStack(
state: QrScannerState,
modifier: Modifier = Modifier,
) {
val progress = state.sequenceProgress
val message =
when {
state.notice != null -> state.notice
progress != null -> stringRes(Res.string.qr_scanner_sequence_progress, progress.first, progress.second)
state.candidates.size > 1 -> stringRes(Res.string.qr_scanner_pick_one)
state.isDark && !state.torchOn -> stringRes(Res.string.qr_scanner_dark_hint)
else -> null
} ?: return
Surface(
modifier = modifier,
shape = RoundedCornerShape(20.dp),
color = Color.Black.copy(alpha = 0.65f),
) {
Text(
text = message,
color = Color.White,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
}
@Composable
private fun ZoomChip(
zoomRatio: Float,
onReset: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.clickable(onClick = onReset),
shape = RoundedCornerShape(16.dp),
color = Color.Black.copy(alpha = 0.55f),
) {
Text(
text = "%.1f× %s".format(zoomRatio, stringRes(Res.string.qr_scanner_zoom_reset)),
color = Color.White,
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
@Composable
private fun OverlayButton(
symbol: MaterialSymbol,
description: String,
onClick: () -> Unit,
highlighted: Boolean = false,
) {
FilledIconButton(
onClick = onClick,
colors =
if (highlighted) {
IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
)
} else {
IconButtonDefaults.filledIconButtonColors(
containerColor = Color.Black.copy(alpha = 0.55f),
contentColor = Color.White,
)
},
modifier = Modifier.size(54.dp),
) {
Icon(symbol = symbol, contentDescription = description, modifier = Modifier.size(26.dp))
}
}
@@ -0,0 +1,233 @@
/*
* 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 androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Everything the scanner UI draws, and the decision of what a frame means.
*
* Kept out of the composable so the accept/dedupe/multi-code rules are one readable block rather
* than conditions scattered through a camera callback.
*/
@Stable
class QrScannerState {
private val sequence = StructuredAppendAccumulator()
/** The size of the image the decoder saw, for mapping [ScanResult.bounds] onto the preview. */
var frame by mutableStateOf(ScanFrame(0, 0))
private set
/**
* Codes currently visible. One entry means we take it; several mean we draw them all and wait
* for a tap, because silently picking one of several codes is how you scan the poster next to
* the one you meant.
*/
var candidates by mutableStateOf<List<ScanResult>>(emptyList())
private set
/** Set while a Structured Append payload is half-captured: captured count to total. */
var sequenceProgress by mutableStateOf<Pair<Int, Int>?>(null)
private set
/** True when the scene has been too dark for [DARK_DWELL_MS]; the UI offers the torch. */
var isDark by mutableStateOf(false)
private set
var torchOn by mutableStateOf(false)
var torchAvailable by mutableStateOf(false)
var zoomRatio by mutableFloatStateOf(1f)
var maxZoomRatio by mutableFloatStateOf(1f)
/**
* Auto-zoom stops for good the first time the user pinches. Someone who has framed the shot
* themselves does not want the camera arguing about it.
*/
var autoZoomEnabled by mutableStateOf(true)
private set
/** A payload we decoded but the caller could not use; drives the explain-what-happened sheet. */
var rejected by mutableStateOf<ScannedPayload?>(null)
/** A transient message (no code in that picture, empty clipboard, decoder unavailable). */
var notice by mutableStateOf<String?>(null)
private var lastSubmittedText: String? = null
private var lastSubmittedAt = 0L
private var darkSinceMs = 0L
/** Milliseconds since anything at all was decoded — what auto-zoom watches. */
var msSinceLastDetection by mutableLongStateOf(0L)
private set
private var lastDetectionMs = 0L
fun onPinch() {
autoZoomEnabled = false
}
fun resetZoom() {
zoomRatio = 1f
}
/**
* Folds one analysed frame into the UI state and decides whether we have an answer.
*
* Returns the payload to hand back to the caller, or null to keep scanning. Returning null is
* the normal case — nothing in frame, several codes in frame, a half-finished multi-part
* code, or the same code we just submitted.
*/
fun onFrame(
scan: FrameScan,
nowMs: Long,
): String? {
frame = scan.frame
updateDarkness(scan.brightness, nowMs)
// Checked on every frame, not just on empty ones: an abandoned half-capture is abandoned
// whether or not the camera is busy reading something else.
if (sequence.dropIfStale(nowMs)) sequenceProgress = null
// Seed the clock on the first frame. Left at zero, the very first empty frame would read
// as "nothing decoded since the epoch" and send auto-zoom hunting before the user has had
// a chance to aim.
if (lastDetectionMs == 0L) lastDetectionMs = nowMs
// Nothing is decided while the "we can't open this" sheet is up: the user is reading it,
// and the offending code is very probably still sitting in front of the lens.
if (rejected != null) {
candidates = emptyList()
return null
}
val found = scan.results.distinctBy { it.text }
if (found.isEmpty()) {
candidates = emptyList()
msSinceLastDetection = nowMs - lastDetectionMs
return null
}
lastDetectionMs = nowMs
msSinceLastDetection = 0
candidates = found
// A multi-part code is never complete on its first part, so it can't be a single answer.
found.firstOrNull { it.isPartOfSequence }?.let { part ->
val joined = sequence.add(part, nowMs)
sequenceProgress = if (joined == null) sequence.captured to sequence.total else null
return joined?.let { accept(it, nowMs) }
}
if (found.size > 1) return null
return accept(found.first().text, nowMs)
}
/** Taking one of several visible codes, because the user tapped it. */
fun onCandidateTapped(
result: ScanResult,
nowMs: Long,
): String? {
candidates = emptyList()
// Deliberately bypasses the dedupe window. That window exists to stop ONE code decoding
// thirty times a second from firing the caller thirty times; a tap is one decision by a
// person, and swallowing it makes the highlight a target that can be tapped with nothing
// happening. The latch is still armed, so the camera frames that follow -- the tapped
// code is very probably still in view -- do not fire it again.
lastSubmittedText = result.text
lastSubmittedAt = nowMs
return result.text
}
/**
* Debounced hand-off.
*
* A code held in front of the lens decodes ~30 times a second. Without this the caller's
* handler fires 30 times, which for a navigation target means 30 stacked screens.
*/
private fun accept(
text: String,
nowMs: Long,
): String? {
if (text == lastSubmittedText && nowMs - lastSubmittedAt < DEDUPE_MS) return null
lastSubmittedText = text
lastSubmittedAt = nowMs
return text
}
/** Called when the caller rejects a payload, so the same code does not re-fire immediately. */
fun onRejected(payload: ScannedPayload) {
rejected = payload
candidates = emptyList()
}
/**
* Dismissing the sheet resumes scanning.
*
* The dedupe latch is deliberately LEFT in place. Clearing it - so the user could retry the
* very same code - meant that the offending code, still sitting in front of the lens, decoded
* again on the next frame and re-opened the sheet immediately: "Scan again" became a button
* that could not be escaped. Keeping the latch lets the camera run; pointing at the same code
* again after [DEDUPE_MS] still re-triggers it, which is the retry that was actually wanted.
*/
fun dismissRejection(nowMs: Long) {
rejected = null
// Restart the latch from now, so the grace period is measured from when the user dismissed
// the sheet rather than from when the code was first read.
lastSubmittedAt = nowMs
}
private fun updateDarkness(
brightness: Float,
nowMs: Long,
) {
if (brightness > DARK_THRESHOLD) {
darkSinceMs = 0
isDark = false
return
}
if (darkSinceMs == 0L) darkSinceMs = nowMs
isDark = nowMs - darkSinceMs >= DARK_DWELL_MS
}
companion object {
/** Long enough that one steady code fires once; short enough to rescan on purpose. */
const val DEDUPE_MS = 1_500L
/** Mean luminance below this reads as "the torch would help". */
const val DARK_THRESHOLD = 0.18f
/** Don't offer the torch for a thumb over the lens or a moment of shadow. */
const val DARK_DWELL_MS = 1_000L
/** How long with nothing decoded before auto-zoom starts hunting. */
const val AUTO_ZOOM_AFTER_MS = 1_200L
/** Ceiling for the auto-zoom sweep — past this, focus and shake beat the extra reach. */
const val AUTO_ZOOM_MAX = 2.5f
}
}
@@ -0,0 +1,151 @@
/*
* 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 androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_copy
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_bunker
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_cashu
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_lightning
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_nostr_unsupported
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_nsec
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_signer
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_text
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_wallet
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_web
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_open_link
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_try_again
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_unsupported_secret
import com.vitorpamplona.amethyst.commons.resources.qr_scanner_unsupported_title
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
/**
* Explains a code we read but could not act on.
*
* This sheet is the whole point of classifying payloads. The old scanner collapsed "you
* cancelled", "the camera never read anything" and "that scanned perfectly but Amethyst has no
* screen for it" into the same silent return to the previous screen (issue #417), which trains
* people to believe the reader is broken when it is working exactly as designed.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScanOutcomeSheet(
payload: ScannedPayload,
onDismiss: () -> Unit,
onOpenLink: (String) -> Unit,
onCopy: (String) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier =
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringRes(Res.string.qr_scanner_unsupported_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = explain(payload),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// A QR code is scanned in public. Key material and pairing secrets never get echoed
// back onto the screen, no matter how useful it would be for debugging.
if (!payload.containsSecret) {
Text(
text = payload.raw,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (payload is ScannedPayload.Web) {
Button(onClick = { onOpenLink(payload.url) }) {
Text(stringRes(Res.string.qr_scanner_open_link))
}
}
if (!payload.containsSecret) {
TextButton(onClick = { onCopy(payload.raw) }) {
Text(stringRes(Res.string.qr_scanner_copy))
}
}
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.qr_scanner_try_again))
}
}
}
}
}
@Composable
private fun explain(payload: ScannedPayload): String =
when (payload) {
is ScannedPayload.Nostr ->
if (payload.entity is NSec) {
stringRes(Res.string.qr_scanner_kind_nsec)
} else {
stringRes(Res.string.qr_scanner_kind_nostr_unsupported)
}
is ScannedPayload.WalletConnect -> stringRes(Res.string.qr_scanner_kind_wallet)
// Split deliberately: Amethyst PUBLISHES bunker:// addresses (it is the signer) and has no
// screen that consumes one, so pointing the user at the signer screen -- as this used to --
// sends them somewhere that cannot accept it.
is ScannedPayload.Bunker -> stringRes(Res.string.qr_scanner_kind_bunker)
is ScannedPayload.NostrConnect -> stringRes(Res.string.qr_scanner_kind_signer)
is ScannedPayload.PrivateKey -> stringRes(Res.string.qr_scanner_kind_nsec)
is ScannedPayload.Lightning -> stringRes(Res.string.qr_scanner_kind_lightning)
is ScannedPayload.Cashu -> stringRes(Res.string.qr_scanner_kind_cashu)
is ScannedPayload.Web -> stringRes(Res.string.qr_scanner_kind_web)
is ScannedPayload.HexPubKey, is ScannedPayload.Unknown -> stringRes(Res.string.qr_scanner_kind_text)
} + if (payload.containsSecret) "\n\n" + stringRes(Res.string.qr_scanner_unsupported_secret) else ""
@@ -0,0 +1,85 @@
/*
* 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
/** A point in the decoder's image space (origin top-left, after crop and rotation). */
data class ScanPoint(
val x: Float,
val y: Float,
)
/**
* The four corners of a decoded symbol, in the decoder's image space.
*
* Kept because they drive three things the old scanner could not do: highlighting the code we
* actually read, letting the user tap the right one when several are in frame, and measuring how
* small the symbol is so auto-zoom knows whether to push in.
*/
data class ScanBounds(
val topLeft: ScanPoint,
val topRight: ScanPoint,
val bottomRight: ScanPoint,
val bottomLeft: ScanPoint,
) {
val centerX: Float get() = (topLeft.x + topRight.x + bottomRight.x + bottomLeft.x) / 4f
val centerY: Float get() = (topLeft.y + topRight.y + bottomRight.y + bottomLeft.y) / 4f
/** The longest edge of the quad — a rotation-independent stand-in for "how big is it". */
val longestSide: Float
get() =
maxOf(
dist(topLeft, topRight),
dist(topRight, bottomRight),
dist(bottomRight, bottomLeft),
dist(bottomLeft, topLeft),
)
private fun dist(
a: ScanPoint,
b: ScanPoint,
): Float {
val dx = a.x - b.x
val dy = a.y - b.y
return kotlin.math.sqrt(dx * dx + dy * dy)
}
}
/**
* One decoded symbol.
*
* [sequenceSize] is greater than zero only for Structured Append codes — a payload split across
* several physical QR codes. [StructuredAppendAccumulator] reassembles those.
*/
data class ScanResult(
val text: String,
val bounds: ScanBounds?,
val sequenceId: String? = null,
val sequenceIndex: Int = -1,
val sequenceSize: Int = -1,
) {
val isPartOfSequence: Boolean get() = sequenceSize > 0 && sequenceIndex >= 0
}
/** The size of the image the decoder was handed, so callers can map [ScanBounds] onto a view. */
data class ScanFrame(
val width: Int,
val height: Int,
)
@@ -0,0 +1,188 @@
/*
* 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 com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
/**
* What a decoded QR string *is*, independent of whether any particular screen can act on it.
*
* The scanner needs this separately from routing: `uriToRoute` answers "where does this
* navigate", and collapses everything it doesn't recognise into `null` — which is how a
* perfectly good scan of an unsupported payload became indistinguishable from the camera never
* reading anything (issue #417). Classifying first lets the UI say *which* of those happened.
*
* Pure Kotlin on purpose: no Android, no `LocalCache`, no navigation. It is the one piece of the
* scanner that can be exhaustively unit-tested on the JVM.
*/
sealed interface ScannedPayload {
/** Exactly what the decoder read, trimmed. */
val raw: String
/**
* True when [raw] carries key material or a pairing secret — an `nsec`, an `ncryptsec`, a
* wallet-connect URI (its `secret=` is spendable), or a NIP-46 URI (its `secret=` authorises
* a signer). Any UI that echoes a payload back to the screen, copies it, or logs it MUST
* check this first. A QR code is scanned in public, over someone's shoulder, by definition.
*/
val containsSecret: Boolean
get() = false
/** A NIP-19 entity, with or without the `nostr:` prefix. */
data class Nostr(
override val raw: String,
val entity: Entity,
) : ScannedPayload {
override val containsSecret get() = entity is NSec
}
/** `nostrconnect://` — an app asking our signer to connect. Carries a `secret`. */
data class NostrConnect(
override val raw: String,
) : ScannedPayload {
override val containsSecret get() = true
}
/** `bunker://` — a remote signer offering itself. Carries a `secret`. */
data class Bunker(
override val raw: String,
) : ScannedPayload {
override val containsSecret get() = true
}
/** `nostr+walletconnect://` and friends. Carries a spendable `secret`. */
data class WalletConnect(
override val raw: String,
) : ScannedPayload {
override val containsSecret get() = true
}
/** A BOLT-11 invoice, an LNURL, or a `lightning:` URI. */
data class Lightning(
override val raw: String,
) : ScannedPayload
/** A Cashu token or payment request. Bearer money — never echo it. */
data class Cashu(
override val raw: String,
) : ScannedPayload {
override val containsSecret get() = true
}
/** An `http(s)://` link. [url] is [raw] with any `web+nostr:` style wrapper removed. */
data class Web(
override val raw: String,
val url: String,
) : ScannedPayload
/**
* A bare 64-character hex pubkey with no bech32 wrapper — what issue #417 hit in 2023 and
* what every "copy the pubkey" web tool still produces. [npub] is the same key encoded, so
* callers can hand it to the normal NIP-19 routing path.
*/
data class HexPubKey(
override val raw: String,
val npub: String,
) : ScannedPayload
/**
* Key material we can recognise but not decode. Two things land here:
*
* - an `ncryptsec`, which `Nip19Parser` lists in its regex but has no branch to parse;
* - an `nsec` the parser rejected — truncated by a half-finished copy, or transcribed with a
* typo into whatever generated the code. It is still a private key, and a damaged one
* still shows all but a few of its characters.
*
* Both are classified by *prefix*, not by parse success: whether a payload is dangerous to
* display cannot depend on whether we happen to be able to read it.
*/
data class PrivateKey(
override val raw: String,
) : ScannedPayload {
override val containsSecret get() = true
}
/** Decoded fine, but it is not anything Amethyst knows how to act on. */
data class Unknown(
override val raw: String,
) : ScannedPayload
}
private val HEX_64 = Regex("^[0-9a-fA-F]{64}$")
private val LIGHTNING_PREFIXES = listOf("lightning:", "lnbc", "lntb", "lnbcrt", "lnurl")
private val WALLET_CONNECT_PREFIXES =
listOf(
"nostr+walletconnect:",
"nostrwalletconnect:",
"nostr+walletconnect://",
"amethyst+walletconnect:",
)
/**
* Classify a decoded QR string.
*
* Scheme checks run before the NIP-19 scan on purpose: a `bunker://` or `nostrconnect://` URI
* embeds a hex pubkey and relay URLs, and letting the (deliberately permissive) NIP-19 regex
* look at those first risks matching a bech32-shaped fragment out of a relay path and routing
* somewhere absurd.
*/
fun classifyScannedPayload(text: String): ScannedPayload {
val raw = text.trim()
if (raw.isEmpty()) return ScannedPayload.Unknown(raw)
val lower = raw.lowercase()
if (lower.startsWith("bunker:")) return ScannedPayload.Bunker(raw)
if (lower.startsWith("nostrconnect:")) return ScannedPayload.NostrConnect(raw)
if (WALLET_CONNECT_PREFIXES.any { lower.startsWith(it) }) return ScannedPayload.WalletConnect(raw)
if (lower.startsWith("cashu") || lower.startsWith("creq")) return ScannedPayload.Cashu(raw)
// Before the NIP-19 scan, and by prefix rather than by parse: an ncryptsec cannot be decoded
// here, so waiting to find out what it is would mean deciding it is harmless.
if (lower.startsWith("ncryptsec1") || lower.startsWith("nostr:ncryptsec1")) {
return ScannedPayload.PrivateKey(raw)
}
if (LIGHTNING_PREFIXES.any { lower.startsWith(it) }) return ScannedPayload.Lightning(raw)
Nip19Parser.uriToRoute(raw)?.let { return ScannedPayload.Nostr(raw, it.entity) }
// An nsec the parser would not take. The parse is tried first so a well-formed one still
// becomes a [ScannedPayload.Nostr] and keeps the routing that logging in by scanning one
// depends on — but a damaged one must not fall through to [ScannedPayload.Unknown], where
// the sheet prints the payload on screen with a Copy button next to it.
if (lower.startsWith("nsec1") || lower.startsWith("nostr:nsec1")) return ScannedPayload.PrivateKey(raw)
if (HEX_64.matches(raw)) {
val npub = runCatching { NPub.create(raw.lowercase()) }.getOrNull()
if (npub != null) return ScannedPayload.HexPubKey(raw, npub)
}
if (lower.startsWith("http://") || lower.startsWith("https://")) {
return ScannedPayload.Web(raw, raw)
}
return ScannedPayload.Unknown(raw)
}
@@ -0,0 +1,119 @@
/*
* 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
/**
* Reassembles a Structured Append payload — one logical string split across several physical QR
* codes, each tagged with a sequence id, an index and a total.
*
* Why bother: a QR code's capacity falls off a cliff as the payload grows, because more data
* means more modules in the same physical space, and small modules are exactly what defeats a
* camera at arm's length. Splitting is how a long payload (a key backup, an `naddr` with several
* relay hints) stays scannable, and Structured Append is the standard way to do it. We could not
* read one at all before.
*
* Not thread-safe; it is driven from the analysis executor only.
*/
class StructuredAppendAccumulator(
private val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
) {
private var sequenceId: String? = null
private var expected: Int = 0
private var lastUpdateMs: Long = 0
private val parts = mutableMapOf<Int, String>()
/** Parts captured so far for the sequence in progress. */
val captured: Int get() = parts.size
/** How many parts the sequence in progress needs in total, or 0 when idle. */
val total: Int get() = expected
/**
* Feeds one decoded part in.
*
* Returns the joined payload once every part has been seen, or null while the sequence is
* still incomplete. A part from a different sequence, or one arriving after [timeoutMs] of
* silence, restarts the accumulation rather than corrupting it — someone who gives up
* halfway and points the camera at a different code should not get a splice of the two.
*/
fun add(
result: ScanResult,
nowMs: Long,
): String? {
if (!result.isPartOfSequence) return null
val id = result.sequenceId
val stale = nowMs - lastUpdateMs > timeoutMs
if (id != sequenceId || expected != result.sequenceSize || stale) {
reset()
sequenceId = id
expected = result.sequenceSize
}
lastUpdateMs = nowMs
parts[result.sequenceIndex] = result.text
if (parts.size < expected) return null
// Built with an explicit loop rather than joinToString: bailing out on a missing index
// needs a real `return`, and joinToString is not inline, so a non-local one is illegal
// there. The count can be right and an index still missing, if a malformed code
// reported an index outside 0 until sequenceSize.
val joined = StringBuilder()
for (index in 0 until expected) {
joined.append(parts[index] ?: return null)
}
reset()
return joined.toString()
}
/**
* Drops a half-captured sequence whose parts stopped arriving, and says whether it did.
*
* Kept here, against this accumulator's own last-update clock, because that is the only clock
* that measures the right thing. The caller cannot substitute "nothing has been decoded at
* all": walking away from a half-scanned poster and pointing the camera at an ordinary code
* keeps decoding something on every frame, so that clock never advances and the abandoned
* sequence is never dropped.
*/
fun dropIfStale(nowMs: Long): Boolean {
if (expected == 0) return false
if (nowMs - lastUpdateMs <= timeoutMs) return false
reset()
return true
}
fun reset() {
sequenceId = null
expected = 0
lastUpdateMs = 0
parts.clear()
}
companion object {
/**
* Long enough to walk around a poster and catch the parts, short enough that an abandoned
* half-sequence does not linger into the next scan.
*/
const val DEFAULT_TIMEOUT_MS = 30_000L
}
}
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.my_fitness_connect_title
import com.vitorpamplona.amethyst.commons.resources.my_fitness_distance
import com.vitorpamplona.amethyst.commons.resources.my_fitness_elevation
import com.vitorpamplona.amethyst.commons.resources.my_fitness_empty
import com.vitorpamplona.amethyst.commons.resources.my_fitness_loading_metrics
import com.vitorpamplona.amethyst.commons.resources.my_fitness_max_heart_rate
import com.vitorpamplona.amethyst.commons.resources.my_fitness_recent
import com.vitorpamplona.amethyst.commons.resources.my_fitness_share
@@ -181,6 +182,7 @@ fun MyFitnessScreen(
// Only offered when it would actually add something: a device with no
// provider gets no banner to act on.
showConnectBanner = current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE,
metricsPending = current.metricsPending,
onDetails = openRationale,
onConnect = requestPermissions,
) { workout, label ->
@@ -226,6 +228,32 @@ private fun ConnectBanner(
}
}
/**
* Shown while the per-session metrics are still being read from Health Connect. The dashboard
* below it is already real — counts, time, streak, the activity split — but its distance,
* calories and heart rate cells appear as each session's metrics land, and a row of numbers
* growing on its own needs saying out loud.
*/
@Composable
private fun MetricsPendingNote() {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringRes(Res.string.my_fitness_loading_metrics),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ConnectPrompt(
onDetails: () -> Unit,
@@ -263,6 +291,7 @@ private fun ConnectPrompt(
private fun Dashboard(
report: WorkoutStats.Report,
showConnectBanner: Boolean,
metricsPending: Boolean,
onDetails: () -> Unit,
onConnect: () -> Unit,
onShare: (DetectedWorkout, String) -> Unit,
@@ -274,12 +303,13 @@ private fun Dashboard(
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
if (showConnectBanner) ConnectBanner(onDetails = onDetails, onConnect = onConnect)
if (metricsPending) MetricsPendingNote()
ThisWeekCard(report, miles)
ConsistencyRow(report)
WindowTotalsCard(report, miles)
ActivityBreakdown(report, miles)
BestEfforts(report, miles)
RecentWorkouts(report, miles, onShare)
RecentWorkouts(report, miles, metricsPending, onShare)
Text(
text = stringRes(Res.string.my_fitness_window_note),
@@ -498,6 +528,7 @@ private fun bestValue(
private fun RecentWorkouts(
report: WorkoutStats.Report,
miles: Boolean,
metricsPending: Boolean,
onShare: (DetectedWorkout, String) -> Unit,
) {
SectionCard(stringRes(Res.string.my_fitness_recent)) {
@@ -525,7 +556,16 @@ private fun RecentWorkouts(
// workout that is already posted would publish a second kind 1301 for
// the same effort — whether it came back from a relay, or is the
// Health Connect copy of one the user shared earlier.
if (!workout.alreadyPublished) {
//
// Nor while the metrics are still loading. The composer route carries
// them as primitives where 0 means absent, so sharing a workout whose
// aggregations have not come back yet posts a kind 1301 with its
// duration and nothing else — and then the workout counts as shared, so
// the full numbers never get their turn. Everything still offering a
// Share button in that window is a Health Connect workout, since a
// published one is by definition already published, so the wait is
// blanket rather than per-workout.
if (!workout.alreadyPublished && !metricsPending) {
TextButton(onClick = { onShare(workout, label) }) {
Text(stringRes(Res.string.my_fitness_share), style = MaterialTheme.typography.labelMedium)
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
import com.vitorpamplona.amethyst.service.workouts.health.publishedWorkoutsOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -42,6 +43,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Duration
import java.time.Instant
@@ -84,6 +86,13 @@ class MyFitnessViewModel : ViewModel() {
data class Ready(
val report: WorkoutStats.Report,
val healthConnect: HealthConnectStatus,
/**
* True while Health Connect's per-session metrics are still arriving. The report is
* real and complete in every other respect — counts, time, streak, active days, the
* per-activity split — but distance, calories, heart rate, steps and elevation are
* still filling in, so the screen says so rather than letting cells appear unexplained.
*/
val metricsPending: Boolean = false,
) : State
}
@@ -93,7 +102,10 @@ class MyFitnessViewModel : ViewModel() {
* Health Connect's contribution. A push source: the platform has no change feed we can
* observe, so [refresh] re-reads it when the screen resumes or a permission is granted.
*/
private val fromHealthConnect = MutableStateFlow<List<DetectedWorkout>>(emptyList())
private val fromHealthConnect = MutableStateFlow(HealthConnectContribution())
/** The in-flight [refresh], cancelled by the next one so two reads never interleave. */
private var refreshJob: Job? = null
/** Null until the first [refresh] resolves, which is what keeps the screen on [State.Loading]. */
private val healthConnectStatus = MutableStateFlow<HealthConnectStatus?>(null)
@@ -112,21 +124,29 @@ class MyFitnessViewModel : ViewModel() {
val state: StateFlow<State> =
combine(fromHealthConnect, fromRelays, healthConnectStatus) { healthConnect, published, status ->
if (status == null) {
State.Loading
} else {
val now = Instant.now()
val since = now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)).epochSecond
// The status check is cheap; reading the workouts is not. Waiting only on the former
// is what lets a user's published log render while their watch data is still coming.
if (status == null) return@combine State.Loading
State.Ready(
report =
WorkoutStats.report(
TrainingLog.merge(healthConnect, published.filter { it.startTimeEpochSeconds >= since }),
now,
),
healthConnect = status,
val now = Instant.now()
val since = now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)).epochSecond
val report =
WorkoutStats.report(
TrainingLog.merge(healthConnect.workouts, published.filter { it.startTimeEpochSeconds >= since }),
now,
)
}
// Nothing to show *yet* is not the same as nothing logged. Going Ready here would
// flash the empty state — or the connect prompt — at a user whose sessions are one
// IPC away, so an empty report keeps waiting while the session list is in flight.
if (report.isEmpty && healthConnect.sessionsPending) return@combine State.Loading
State.Ready(
report = report,
healthConnect = status,
metricsPending = healthConnect.metricsPending,
)
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), State.Loading)
@@ -141,25 +161,85 @@ class MyFitnessViewModel : ViewModel() {
* numbers on display. The published side needs no refresh — it is observed.
*/
fun refresh(context: Context) {
viewModelScope.launch {
val status = healthConnectStatus(context)
// A resume while the previous read is still running would leave two collectors writing
// fromHealthConnect, and the slower one could land a stale list last.
refreshJob?.cancel()
refreshJob =
viewModelScope.launch {
val status = healthConnectStatus(context)
val hc = manager
fromHealthConnect.value =
if (status == HealthConnectStatus.CONNECTED) {
val now = Instant.now()
manager?.readWorkouts(now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)), now).orEmpty()
} else {
emptyList()
// Read through the local rather than the field: nothing may set sessionsPending
// without a reader that will clear it again, or an empty dashboard waits forever.
if (status != HealthConnectStatus.CONNECTED || hc == null) {
fromHealthConnect.value = HealthConnectContribution()
healthConnectStatus.value = status
return@launch
}
healthConnectStatus.value = status
// Published before the read, not after: the status is what the screen is gated on,
// and it is now known. The workouts arrive into an already-rendered dashboard.
// The previous read's workouts stay up meanwhile, so a resume re-reads in place
// rather than blanking a dashboard that is already correct.
fromHealthConnect.value = fromHealthConnect.value.copy(sessionsPending = true)
healthConnectStatus.value = status
val now = Instant.now()
hc
.readWorkoutsProgressively(now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)), now)
.collect { read -> fromHealthConnect.value = fromHealthConnect.value.after(read) }
}
}
/**
* Both calls here are binder round trips — a PackageManager query, and a bind to the Health
* Connect service that [HealthConnectManager] makes lazily on first use — so they run off the
* main thread. [viewModelScope] is `Dispatchers.Main.immediate`, which would otherwise stall
* the frame that opens the screen.
*/
private suspend fun healthConnectStatus(context: Context): HealthConnectStatus =
withContext(Dispatchers.IO) {
if (!HealthConnectManager.isAvailable(context)) return@withContext HealthConnectStatus.UNAVAILABLE
val hc = manager ?: HealthConnectManager(context.applicationContext).also { manager = it }
if (hc.hasAllPermissions()) HealthConnectStatus.CONNECTED else HealthConnectStatus.AVAILABLE
}
}
/**
* Health Connect's contribution to the dashboard, and how far along reading it is.
*
* [sessionsPending] and [metricsPending] are the two stages of
* [HealthConnectManager.readWorkoutsProgressively]: the session list costs one IPC, the metrics
* cost one per session. They are tracked separately because they mean different things to the
* screen — an empty dashboard must not say "nothing logged yet" while the sessions are still
* coming, but it can show real counts and times while the metrics are.
*/
data class HealthConnectContribution(
val workouts: List<DetectedWorkout> = emptyList(),
val sessionsPending: Boolean = false,
val metricsPending: Boolean = false,
) {
/**
* Folds one stage of a read in — and declines the ones that would take information off a
* screen that already has it.
*
* A stage-1 result carries no metrics. On a first load that is exactly what makes the
* dashboard appear in one IPC instead of dozens. On a re-read of an already-populated
* dashboard — [MyFitnessViewModel.refresh] runs on every resume — publishing it would strip
* the distance, calories, heart-rate, steps and elevation cells and most of Best Efforts for
* as long as stage 2 takes, then put them back. So a partial stage only reaches the screen
* when there is nothing better on it already; otherwise the previous read's numbers stay up,
* correct and unannotated, and stage 2 swaps them atomically.
*/
fun after(read: HealthConnectManager.WorkoutRead): HealthConnectContribution =
if (read.metricsPending && workouts.isNotEmpty()) {
copy(sessionsPending = false)
} else {
HealthConnectContribution(
workouts = read.workouts,
sessionsPending = false,
metricsPending = read.metricsPending,
)
}
}
private suspend fun healthConnectStatus(context: Context): HealthConnectStatus {
if (!HealthConnectManager.isAvailable(context)) return HealthConnectStatus.UNAVAILABLE
val hc = manager ?: HealthConnectManager(context.applicationContext).also { manager = it }
return if (hc.hasAllPermissions()) HealthConnectStatus.CONNECTED else HealthConnectStatus.AVAILABLE
}
}
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.tor
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.databind.ObjectMapper
/**
* Pure, file- and JNI-free parsers over Arti's persisted guard sample
@@ -42,7 +42,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
* circuit — i.e. a real bootstrap reached the guard-confirmation stage.
*/
object ArtiGuardState {
private val mapper = jacksonObjectMapper()
private val mapper = ObjectMapper()
/** Convenience for tests/callers holding the raw file text. */
fun parse(json: String): JsonNode = mapper.readTree(json)
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.tor
import android.content.Context
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.databind.ObjectMapper
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -206,7 +206,7 @@ class TorService(
val file = guardsFile()
if (!file.exists()) return null
return try {
jacksonObjectMapper().readTree(file)
ObjectMapper().readTree(file)
} catch (e: Exception) {
Log.w("TorService") { "Could not inspect guards.json: ${e.message}" }
null
@@ -0,0 +1,216 @@
/*
* Copyright 2021 Axel Waggershauser
*/
// SPDX-License-Identifier: Apache-2.0
// -----------------------------------------------------------------------------
// VENDORED, DO NOT EDIT OR REFORMAT.
//
// Copied verbatim from zxing-cpp v3.1.1:
// wrappers/android/zxingcpp/src/main/java/zxingcpp/BarcodeReader.kt
//
// We build libzxingcpp_android.so from source ourselves (tools/zxing-cpp-build)
// instead of consuming the prebuilt io.github.zxing-cpp:android AAR, so the
// Kotlin half of that AAR has to come with it.
//
// The package and class name are load-bearing: the native library exports
// Java_zxingcpp_BarcodeReader_readYBuffer / _readBitmap, so moving or renaming
// this class breaks the JNI lookup at runtime with no build error. R8 would do
// the same, which is why amethyst/proguard-rules.pro keeps zxingcpp.** -- that
// rule used to arrive as the AAR's consumer rule and is ours to carry now.
//
// It keeps its upstream Apache-2.0 header and is excluded from spotless in the
// root build.gradle.kts: stamping our MIT header on someone else's file would
// misstate its provenance. To update, re-run build-zxingcpp.sh at a new tag and
// re-copy this file from the pinned clone so the two halves cannot drift.
// -----------------------------------------------------------------------------
package zxingcpp
import android.graphics.Bitmap
import android.graphics.ImageFormat
import android.graphics.Point
import android.graphics.Rect
import android.os.Build
import androidx.camera.core.ImageProxy
import java.nio.ByteBuffer
public class BarcodeReader(public var options: Options = Options()) {
private val supportedYUVFormats: List<Int> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
listOf(ImageFormat.YUV_420_888, ImageFormat.YUV_422_888, ImageFormat.YUV_444_888)
} else {
listOf(ImageFormat.YUV_420_888)
}
init {
System.loadLibrary("zxingcpp_android")
}
// Enumerates barcode formats known to this package.
// Note that this has to be kept synchronized with native (C++/JNI) side.
public enum class Format(public val value: Int) {
NONE (0x0000),
ALL (0x2A2A),
ALL_READABLE (0x722A),
ALL_CREATABLE (0x772A),
ALL_LINEAR (0x6C2A),
ALL_MATRIX (0x6D2A),
ALL_GS1 (0x472A),
ALL_RETAIL (0x522A),
ALL_INDUSTRIAL (0x492A),
CODABAR (0x2046),
CODE_39 (0x2041),
CODE_39_STD (0x7341),
CODE_39_EXT (0x6541),
CODE_32 (0x3241),
PZN (0x7041),
CODE_93 (0x2047),
CODE_128 (0x2043),
ITF (0x2049),
ITF_14 (0x3449),
DATA_BAR (0x2065),
DATA_BAR_OMNI (0x6F65),
DATA_BAR_STK (0x7365),
DATA_BAR_STK_OMNI (0x4F65),
DATA_BAR_LTD (0x6C65),
DATA_BAR_EXP (0x6565),
DATA_BAR_EXP_STK (0x4565),
EAN_UPC (0x2045),
EAN_13 (0x3145),
EAN_8 (0x3845),
EAN_5 (0x3545),
EAN_2 (0x3245),
ISBN (0x6945),
UPC_A (0x6145),
UPC_E (0x6545),
TELEPEN (0x2042),
TELEPEN_ALPHA (0x3042),
TELEPEN_NUMERIC (0x3142),
OTHER_BARCODE (0x2058),
DX_FILM_EDGE (0x7858),
PDF_417 (0x204C),
COMPACT_PDF_417 (0x634C),
MICRO_PDF_417 (0x6D4C),
AZTEC (0x207A),
AZTEC_CODE (0x637A),
AZTEC_RUNE (0x727A),
QR_CODE (0x2051),
QR_CODE_MODEL_1 (0x3151),
QR_CODE_MODEL_2 (0x3251),
MICRO_QR_CODE (0x6D51),
RMQR_CODE (0x7251),
DATA_MATRIX (0x2064),
MAXI_CODE (0x2055),
}
public enum class ContentType {
TEXT, BINARY, MIXED, GS1, ISO15434, UNKNOWN_ECI
}
public enum class Binarizer {
LOCAL_AVERAGE, GLOBAL_HISTOGRAM, FIXED_THRESHOLD, BOOL_CAST
}
public enum class EanAddOnSymbol {
IGNORE, READ, REQUIRE
}
public enum class TextMode {
PLAIN, ECI, HRI, ESCAPED, HEX, HEX_ECI
}
public enum class ErrorType {
NONE, FORMAT, CHECKSUM, UNSUPPORTED
}
public data class Options(
var formats: Set<Format> = setOf(),
var tryHarder: Boolean = false,
var tryRotate: Boolean = false,
var tryInvert: Boolean = false,
var tryDownscale: Boolean = false,
var tryDenoise: Boolean = false,
var isPure: Boolean = false,
var binarizer: Binarizer = Binarizer.LOCAL_AVERAGE,
var downscaleFactor: Int = 3,
var downscaleThreshold: Int = 500,
var minLineCount: Int = 2,
var maxNumberOfSymbols: Int = 0xff,
var validateOptionalChecksum: Boolean = false,
@Deprecated("See https://github.com/zxing-cpp/zxing-cpp/discussions/704")
var tryCode39ExtendedMode: Boolean = true,
@Deprecated("Use validateOptionalChecksum")
var validateCode39CheckSum: Boolean = false,
@Deprecated("Use validateOptionalChecksum")
var validateITFCheckSum: Boolean = false,
var returnErrors: Boolean = false,
var eanAddOnSymbol: EanAddOnSymbol = EanAddOnSymbol.IGNORE,
var textMode: TextMode = TextMode.HRI,
)
public data class Error(
val type: ErrorType,
val message: String
)
public data class Position(
val topLeft: Point,
val topRight: Point,
val bottomRight: Point,
val bottomLeft: Point,
val orientation: Double
)
public data class Result(
val format: Format,
val bytes: ByteArray?,
val text: String?,
val contentType: ContentType,
val position: Position,
val orientation: Int,
val ecLevel: String?,
val symbologyIdentifier: String?,
val sequenceSize: Int,
val sequenceIndex: Int,
val sequenceId: String?,
val readerInit: Boolean,
val lineCount: Int,
val error: Error?,
)
public val lastReadTime : Int = 0 // runtime of last read call in ms (for debugging purposes only)
public fun read(image: ImageProxy): List<Result> {
check(image.format in supportedYUVFormats) {
"Invalid image format: ${image.format}. Must be one of: $supportedYUVFormats"
}
return readYBuffer(
image.planes[0].buffer,
image.planes[0].rowStride,
image.cropRect.left,
image.cropRect.top,
image.cropRect.width(),
image.cropRect.height(),
image.imageInfo.rotationDegrees,
options
)
}
public fun read(
bitmap: Bitmap, cropRect: Rect = Rect(), rotation: Int = 0
): List<Result> {
return readBitmap(
bitmap, cropRect.left, cropRect.top, cropRect.width(), cropRect.height(), rotation, options
)
}
private external fun readYBuffer(
yBuffer: ByteBuffer, rowStride: Int, left: Int, top: Int, width: Int, height: Int, rotation: Int, options: Options
): List<Result>
private external fun readBitmap(
bitmap: Bitmap, left: Int, top: Int, width: Int, height: Int, rotation: Int, options: Options
): List<Result>
}
Binary file not shown.

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