diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 0ea720bc54..9e234da0b4 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -504,8 +504,9 @@ dependencies { implementation(libs.accompanist.permissions) // For QR generation + // ZXing core encodes the QR codes we display; zxing-cpp decodes the ones we scan. implementation(libs.zxing) - implementation(libs.zxing.embedded) + implementation(libs.zxing.cpp) // OpenStreetMap tiles for road event location maps (kind 1315/1316) implementation(libs.osmdroid.android) diff --git a/amethyst/plans/2026-09-15-qr-reader-overhaul.md b/amethyst/plans/2026-09-15-qr-reader-overhaul.md index caff7d5e4b..9f4c49b50d 100644 --- a/amethyst/plans/2026-09-15-qr-reader-overhaul.md +++ b/amethyst/plans/2026-09-15-qr-reader-overhaul.md @@ -1,6 +1,6 @@ # QR Reader Overhaul -**Status:** queued (design + task plan; nothing implemented yet) +**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 @@ -300,3 +300,50 @@ Half of a scan is the code on the *other* screen. 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 | **Not done.** No device or emulator in this environment, so the fixture corpus and `QrDecodeCorpusTest` could not be built *or run*, and none of the before/after decode-rate numbers that §1 and §2.1 call for exist yet. The gate it was supposed to provide — "drop zxing-cpp if it doesn't beat ZXing-Java" — has therefore not been exercised. | +| 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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt index 9bbc15ae85..f760b19863 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt @@ -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,7 +59,6 @@ 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 @@ -221,45 +218,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 - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/KeepScreenBrightAndAwake.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/KeepScreenBrightAndAwake.kt new file mode 100644 index 0000000000..c09e5029e6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/KeepScreenBrightAndAwake.kt @@ -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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt index a31a83b21c..cb3c1a7b64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt index ccb3c976a5..355c84bbce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeScanner.kt @@ -20,64 +20,78 @@ */ 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 + Log.e("NIP19 Scanner", "Error parsing $contents", 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 + }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt index 5f8e0c7fba..eebce7f091 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt @@ -195,6 +195,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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/BarcodeDecoder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/BarcodeDecoder.kt new file mode 100644 index 0000000000..67f6a03ee5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/BarcodeDecoder.kt @@ -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 + + /** Decodes an imported still (gallery, clipboard, share). Called off the main thread. */ + fun decode( + bitmap: Bitmap, + effort: DecodeEffort = DecodeEffort.Thorough, + ): List +} + +/** + * [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 = + 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 = reader(effort).read(bitmap, Rect(0, 0, bitmap.width, bitmap.height)).toScanResults() + + private fun List.toScanResults(): List = + 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrFrameAnalyzer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrFrameAnalyzer.kt new file mode 100644 index 0000000000..d526f5e914 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrFrameAnalyzer.kt @@ -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, + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrImageImport.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrImageImport.kt new file mode 100644 index 0000000000..28e1d2601f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrImageImport.kt @@ -0,0 +1,171 @@ +/* + * 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.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 = + 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) + } + + for (sampleSize in sampleSizes) { + val found = decodeAt(context, uri, sampleSize, upscale = false, decoder) + if (found.isNotEmpty()) return@withContext found + } + + if (longestEdge <= SMALL_IMAGE_EDGE) { + 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. */ + 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 + return clip.getItemAt(0)?.uri + } + + private fun decodeAt( + context: Context, + uri: Uri, + sampleSize: Int, + upscale: Boolean, + decoder: BarcodeDecoder, + ): List { + 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: 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: 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerDialog.kt new file mode 100644 index 0000000000..02e01a5130 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerDialog.kt @@ -0,0 +1,610 @@ +/* + * 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.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_try_again +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)) + } + } + + TextButton(onClick = onDismiss) { + Text(stringRes(Res.string.qr_scanner_try_again), 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(Channel.CONFLATED) } + + val previewView = + remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + } + + var camera by remember { mutableStateOf(null) } + var provider by remember { mutableStateOf(null) } + var viewSize by remember { mutableStateOf(IntSize.Zero) } + var focusRing by remember { mutableStateOf(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() }, + onOpenLink = { url -> + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } + state.dismissRejection() + currentOnDismiss() + }, + onCopy = { text -> + copyToClipboard(context, text) + state.dismissRejection() + }, + ) + } +} + +/** 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) + val radius = maxOf(bounds.longestSide, 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() +} + +private 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerOverlay.kt new file mode 100644 index 0000000000..ac9fda3208 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerOverlay.kt @@ -0,0 +1,309 @@ +/* + * 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)) + +/** 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)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerState.kt new file mode 100644 index 0000000000..a2d1119a7f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/QrScannerState.kt @@ -0,0 +1,212 @@ +/* + * 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.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>(emptyList()) + private set + + /** Set while a Structured Append payload is half-captured: captured count to total. */ + var sequenceProgress by mutableStateOf?>(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(null) + + /** A transient message (no code in that picture, empty clipboard, decoder unavailable). */ + var notice by mutableStateOf(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 mutableStateOf(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) + + // 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() + return accept(result.text, nowMs) + } + + /** + * 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 clears the dedupe latch so the user can retry the very same code. */ + fun dismissRejection() { + rejected = null + lastSubmittedText = null + lastSubmittedAt = 0 + } + + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScanOutcomeSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScanOutcomeSheet.kt new file mode 100644 index 0000000000..1352a805b6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScanOutcomeSheet.kt @@ -0,0 +1,145 @@ +/* + * 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_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) + is ScannedPayload.Bunker, is ScannedPayload.NostrConnect -> stringRes(Res.string.qr_scanner_kind_signer) + 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 "" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScanResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScanResult.kt new file mode 100644 index 0000000000..ab0c14b126 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScanResult.kt @@ -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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScannedPayload.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScannedPayload.kt new file mode 100644 index 0000000000..2d07c6957a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScannedPayload.kt @@ -0,0 +1,159 @@ +/* + * 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 + + /** 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) + if (LIGHTNING_PREFIXES.any { lower.startsWith(it) }) return ScannedPayload.Lightning(raw) + + Nip19Parser.uriToRoute(raw)?.let { return ScannedPayload.Nostr(raw, it.entity) } + + 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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/StructuredAppendAccumulator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/StructuredAppendAccumulator.kt new file mode 100644 index 0000000000..964ec3ef9a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/StructuredAppendAccumulator.kt @@ -0,0 +1,94 @@ +/* + * 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() + + /** 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 + + val joined = (0 until expected).joinToString("") { parts[it] ?: return null } + reset() + return joined + } + + fun reset() { + sequenceId = null + expected = 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 + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScannedPayloadTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScannedPayloadTest.kt new file mode 100644 index 0000000000..828e0609b2 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScannedPayloadTest.kt @@ -0,0 +1,139 @@ +/* + * 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.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScannedPayloadTest { + private val pubkeyHex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private val npub = NPub.create(pubkeyHex) + + @Test + fun `npub with and without the nostr prefix both classify as nostr`() { + assertTrue(classifyScannedPayload(npub) is ScannedPayload.Nostr) + assertTrue(classifyScannedPayload("nostr:$npub") is ScannedPayload.Nostr) + assertTrue(classifyScannedPayload(" $npub ") is ScannedPayload.Nostr) + } + + @Test + fun `a bare hex pubkey is re-encoded rather than rejected`() { + val payload = classifyScannedPayload(pubkeyHex) + + assertTrue(payload is ScannedPayload.HexPubKey) + assertEquals(npub, (payload as ScannedPayload.HexPubKey).npub) + } + + @Test + fun `uppercase hex is accepted and normalises to the same npub`() { + val payload = classifyScannedPayload(pubkeyHex.uppercase()) + + assertTrue(payload is ScannedPayload.HexPubKey) + assertEquals(npub, (payload as ScannedPayload.HexPubKey).npub) + } + + @Test + fun `hex of the wrong length is not a pubkey`() { + assertTrue(classifyScannedPayload(pubkeyHex.drop(1)) is ScannedPayload.Unknown) + assertTrue(classifyScannedPayload(pubkeyHex + "ab") is ScannedPayload.Unknown) + } + + @Test + fun `signer and wallet schemes win over the nip19 scan`() { + assertTrue(classifyScannedPayload("bunker://$pubkeyHex?relay=wss%3A%2F%2Frelay.example") is ScannedPayload.Bunker) + assertTrue(classifyScannedPayload("nostrconnect://$pubkeyHex?relay=wss://r.example&secret=abc") is ScannedPayload.NostrConnect) + assertTrue(classifyScannedPayload("nostr+walletconnect://$pubkeyHex?relay=wss://r.example&secret=abc") is ScannedPayload.WalletConnect) + assertTrue(classifyScannedPayload("nostrwalletconnect://$pubkeyHex?secret=abc") is ScannedPayload.WalletConnect) + } + + @Test + fun `scheme matching ignores case`() { + assertTrue(classifyScannedPayload("BUNKER://$pubkeyHex") is ScannedPayload.Bunker) + assertTrue(classifyScannedPayload("Nostr+WalletConnect://$pubkeyHex") is ScannedPayload.WalletConnect) + } + + @Test + fun `lightning invoices and lnurls are recognised`() { + assertTrue(classifyScannedPayload("lnbc1u1pjxyz") is ScannedPayload.Lightning) + assertTrue(classifyScannedPayload("lightning:lnbc1u1pjxyz") is ScannedPayload.Lightning) + assertTrue(classifyScannedPayload("LNURL1DP68GURN8GHJ7") is ScannedPayload.Lightning) + } + + @Test + fun `web links are recognised and carry their url`() { + val payload = classifyScannedPayload("https://example.com/some/page") + + assertTrue(payload is ScannedPayload.Web) + assertEquals("https://example.com/some/page", (payload as ScannedPayload.Web).url) + } + + @Test + fun `an njump link resolves as the entity in its path, not as a web page`() { + // The NIP-19 scan runs before the http check, and matches anywhere in the string. That is + // deliberate: a scanned njump/nostr.at link should open the profile in the app rather + // than a web page whose only job is to redirect back into one. + assertTrue(classifyScannedPayload("https://njump.to/$npub") is ScannedPayload.Nostr) + } + + @Test + fun `plain text and blanks fall through to unknown`() { + assertTrue(classifyScannedPayload("WIFI:S:cafe;T:WPA;P:hunter2;;") is ScannedPayload.Unknown) + assertTrue(classifyScannedPayload("") is ScannedPayload.Unknown) + assertTrue(classifyScannedPayload(" ") is ScannedPayload.Unknown) + } + + @Test + fun `everything carrying key material or a pairing secret is marked secret`() { + val nsec = classifyScannedPayload(NSEC_FIXTURE) + assertTrue(nsec is ScannedPayload.Nostr) + assertTrue((nsec as ScannedPayload.Nostr).entity is NSec) + assertTrue(nsec.containsSecret) + + assertTrue(classifyScannedPayload("bunker://$pubkeyHex?secret=abc").containsSecret) + assertTrue(classifyScannedPayload("nostrconnect://$pubkeyHex?secret=abc").containsSecret) + assertTrue(classifyScannedPayload("nostr+walletconnect://$pubkeyHex?secret=abc").containsSecret) + assertTrue(classifyScannedPayload("cashuBo2Ftd").containsSecret) + } + + @Test + fun `public payloads are not marked secret`() { + assertFalse(classifyScannedPayload(npub).containsSecret) + assertFalse(classifyScannedPayload(pubkeyHex).containsSecret) + assertFalse(classifyScannedPayload("https://example.com").containsSecret) + assertFalse(classifyScannedPayload("lnbc1u1pjxyz").containsSecret) + assertFalse(classifyScannedPayload("just some text").containsSecret) + } + + @Test + fun `raw is always the trimmed input`() { + assertEquals(npub, classifyScannedPayload("\n $npub \t").raw) + } + + companion object { + /** A throwaway key, generated here so no real secret ever reaches a source file. */ + private val NSEC_FIXTURE = ByteArray(32) { (it + 1).toByte() }.toNsec() + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/StructuredAppendAccumulatorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/StructuredAppendAccumulatorTest.kt new file mode 100644 index 0000000000..e20b9106b4 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/StructuredAppendAccumulatorTest.kt @@ -0,0 +1,113 @@ +/* + * 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 org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StructuredAppendAccumulatorTest { + private fun part( + text: String, + index: Int, + size: Int, + id: String = "seq", + ) = ScanResult(text = text, bounds = null, sequenceId = id, sequenceIndex = index, sequenceSize = size) + + @Test + fun `parts in order join once the last one lands`() { + val acc = StructuredAppendAccumulator() + + assertNull(acc.add(part("abc", 0, 3), 0)) + assertNull(acc.add(part("def", 1, 3), 100)) + assertEquals("abcdefghi", acc.add(part("ghi", 2, 3), 200)) + } + + @Test + fun `parts out of order still join in index order`() { + val acc = StructuredAppendAccumulator() + + assertNull(acc.add(part("ghi", 2, 3), 0)) + assertNull(acc.add(part("abc", 0, 3), 10)) + assertEquals("abcdefghi", acc.add(part("def", 1, 3), 20)) + } + + @Test + fun `a repeated part does not complete the sequence`() { + val acc = StructuredAppendAccumulator() + + assertNull(acc.add(part("abc", 0, 3), 0)) + assertNull(acc.add(part("abc", 0, 3), 10)) + assertNull(acc.add(part("abc", 0, 3), 20)) + assertEquals(1, acc.captured) + } + + @Test + fun `progress reports captured against total`() { + val acc = StructuredAppendAccumulator() + + acc.add(part("a", 0, 3), 0) + assertEquals(1, acc.captured) + assertEquals(3, acc.total) + + acc.add(part("b", 1, 3), 10) + assertEquals(2, acc.captured) + } + + @Test + fun `a different sequence id restarts rather than splicing`() { + val acc = StructuredAppendAccumulator() + + acc.add(part("abc", 0, 2, id = "one"), 0) + assertNull(acc.add(part("xyz", 0, 2, id = "two"), 10)) + assertEquals(1, acc.captured) + assertEquals("xyzuvw", acc.add(part("uvw", 1, 2, id = "two"), 20)) + } + + @Test + fun `a part arriving after the timeout restarts the sequence`() { + val acc = StructuredAppendAccumulator(timeoutMs = 1_000) + + acc.add(part("abc", 0, 2), 0) + // The second part shows up two seconds late: treat it as the start of a new attempt + // rather than half of an abandoned one. + assertNull(acc.add(part("def", 1, 2), 3_000)) + assertEquals(1, acc.captured) + } + + @Test + fun `a result that is not part of a sequence is ignored`() { + val acc = StructuredAppendAccumulator() + + assertNull(acc.add(ScanResult("plain", bounds = null), 0)) + assertEquals(0, acc.captured) + } + + @Test + fun `completing a sequence clears the accumulator for the next one`() { + val acc = StructuredAppendAccumulator() + + acc.add(part("a", 0, 2), 0) + assertEquals("ab", acc.add(part("b", 1, 2), 10)) + assertEquals(0, acc.captured) + assertEquals(0, acc.total) + } +} diff --git a/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf index ee75754b15..56d4724a42 100644 Binary files a/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index cdb9318316..259a1a8869 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -199,6 +199,35 @@ Profile Banner Point to the QR Code + Amethyst needs the camera to scan QR codes. Nothing is recorded or uploaded — frames are decoded on the device and discarded. + Allow camera + Camera access is turned off for Amethyst. You can turn it back on in Android settings. + Open settings + The QR decoder could not start on this device. You can still paste the code as text. + Turn on the light + Turn off the light + It is dark — try the light + Scan a picture + Paste + No QR code found in that picture + Nothing on the clipboard to scan + Tap the code you want + Captured %1$s of %2$s parts — keep going + Reset zoom + Amethyst can\'t open this code + This code carries a private key or a pairing secret, so its contents are not shown here. Use the screen that expects it. + This is a private key. Paste it on the login screen instead. + This is a wallet connection. Add it from Wallet settings. + This is a remote signer. Add it from the NIP-46 signer screen. + This is a Lightning invoice. + This is a Cashu token. + This is a web link. + This is plain text, not a Nostr code. + This is a Nostr code, but not one this screen can open. + Open link + Copy + Copied + Scan again Show QR Profile Picture Your Profile Picture diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index c4425921a5..1ac39519bf 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -115,6 +115,8 @@ object MaterialSymbols { val FileOpen = MaterialSymbol("\uEAF3") val FilterAlt = MaterialSymbol("\uEF4F") val FitnessCenter = MaterialSymbol("\uEB43") + val FlashlightOff = MaterialSymbol("\uF00A") + val FlashlightOn = MaterialSymbol("\uF00B") val Folder = MaterialSymbol("\uE2C7") val FolderZip = MaterialSymbol("\uEB2C") val ForkRight = MaterialSymbol("\uEBAC") @@ -189,6 +191,7 @@ object MaterialSymbols { val PersonRemove = MaterialSymbol("\uEF66") val Phone = MaterialSymbol("\uF0D4") val Photo = MaterialSymbol("\uE432") + val PhotoLibrary = MaterialSymbol("\uE413") val PictureAsPdf = MaterialSymbol("\uE415") val PictureInPicture = MaterialSymbol("\uE8AA") val PlayArrow = MaterialSymbol("\uE037") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08582067ef..3dd4e182d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -93,7 +93,8 @@ jcodec = "0.2.5" commonsImaging = "1.0.0-alpha6" thumbnailator = "0.4.21" zxing = "3.5.4" -zxingAndroidEmbedded = "4.3.0" +# zxing-cpp: Apache-2.0. Native QR/barcode detector used by the in-app CameraX scanner. +zxingCpp = "3.1.1" webkit = "1.17.0" # Cross-process UI embedding (SurfaceControlViewHost wrapper) for the in-app browser surface. Apache-2.0. privacysandboxUi = "1.0.0-alpha17" @@ -252,7 +253,7 @@ vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", ver zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } -zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } +zxing-cpp = { group = "io.github.zxing-cpp", name = "android", version.ref = "zxingCpp" } androidx-window-core-android = { group = "androidx.window", name = "window-core-android", version.ref = "windowCoreAndroid" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinTest" }