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