mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
feat(qr): rebuild the QR reader on CameraX + zxing-cpp
The old scanner handed the whole job to zxing-android-embedded's CaptureActivity, which is a Camera1 app: autofocus is a 2s timer rather than continuous, there is no zoom and no discoverable torch, the decode is cropped to the viewfinder rect, and each frame gets exactly one un-retried attempt. We also passed MIXED_SCAN, which inverts alternate frames and so threw away half of every scan of an ordinary dark-on-light code. Replace it with an in-app Compose scanner on CameraX (already a declared but unused dependency) behind a BarcodeDecoder interface, with zxing-cpp as the engine: - Continuous autofocus, tap-to-focus, pinch zoom and a torch button; the analysis frame's Y plane gives mean luminance for free, so the torch is offered exactly when the scene is dark. - Auto-zoom sweeps 1x-2.5x while nothing is decoding, and stops for good once the user pinches. A code too small in frame is the one failure no amount of decoder tuning can fix. - tryInvert/tryRotate/tryDownscale on every frame and tryHarder/tryDenoise on every fifth, so a hard code gets several expensive attempts a second without the frame rate collapsing for easy ones. - The whole frame is decoded, not a centred box, and the preview and analysis use cases share one ViewPort so the overlay can highlight what we read. - Several codes in frame become tappable choices instead of a coin flip. - Structured Append (multi-part) codes are reassembled; we could not read them. Decoding a code we cannot route no longer looks identical to not reading one (issue #417): payloads are classified, and an unsupported one keeps the camera running and explains itself. Key material and pairing secrets are never echoed into that sheet -- a QR code is scanned in public by definition. A bare 64-char hex pubkey is re-encoded as an npub rather than rejected. Codes can now also be read from the gallery or the clipboard, which is how most of them actually arrive. On the display side, ShowQRScreen now pins brightness and keeps the screen awake (extracted from ShareNoteAsQrScreen, which already did), and the finder patterns' corner rounding scales with module size instead of a fixed 20px that deformed small codes. Licensing: io.github.zxing-cpp:android is Apache-2.0 (verified against its published POM) -- permissive, no linking-exception question. It adds a prebuilt .so per ABI, ~0.8 MB compressed on arm64 after the existing ABI split, partly returned by dropping zxing-android-embedded. Its AAR ships a consumer ProGuard keep rule, so R8 does not break the JNI boundary. Not yet verified: the plan's phase-0 decode corpus and the manual scan matrix both need a camera, so every camera-facing path here is compile-verified and reasoned-through only. Tracked in the plan's new section 7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0134jvyriixNTHST4WRbbqbX
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
-45
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+77
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -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
|
||||
|
||||
+56
-42
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+4
@@ -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(
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import androidx.camera.core.ImageProxy
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import zxingcpp.BarcodeReader
|
||||
|
||||
/**
|
||||
* How hard to work on one image.
|
||||
*
|
||||
* The live camera runs [Fast] on most frames and [Thorough] on every Nth, so a difficult code
|
||||
* still gets the expensive treatment a few times a second without dropping the frame rate for
|
||||
* the easy ones. Imported stills always get [Thorough] — there is only one image and the user is
|
||||
* waiting on it.
|
||||
*/
|
||||
enum class DecodeEffort {
|
||||
Fast,
|
||||
Thorough,
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes QR codes out of camera frames and still images.
|
||||
*
|
||||
* An interface, not a class, because the engine is a choice we want to be able to re-make: the
|
||||
* camera plumbing, the overlay and every test above this line are engine-agnostic.
|
||||
*/
|
||||
interface BarcodeDecoder {
|
||||
/** Decodes straight from a CameraX analysis frame. Called on the analysis executor. */
|
||||
fun decode(
|
||||
image: ImageProxy,
|
||||
effort: DecodeEffort,
|
||||
): List<ScanResult>
|
||||
|
||||
/** Decodes an imported still (gallery, clipboard, share). Called off the main thread. */
|
||||
fun decode(
|
||||
bitmap: Bitmap,
|
||||
effort: DecodeEffort = DecodeEffort.Thorough,
|
||||
): List<ScanResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* [BarcodeDecoder] on zxing-cpp (Apache-2.0).
|
||||
*
|
||||
* Every option here maps to a failure the previous zxing-android-embedded scanner had:
|
||||
*
|
||||
* - `tryInvert` replaces the old `MIXED_SCAN`, which inverted *alternate frames* and so threw
|
||||
* away half of all decode attempts on ordinary dark-on-light codes. zxing-cpp does the
|
||||
* inverted pass as a fallback inside one call, so nothing is wasted.
|
||||
* - `tryRotate` picks up codes held sideways.
|
||||
* - `tryDownscale` finds codes that fill most of the frame, which a fixed-scale detector misses.
|
||||
* - `tryHarder`/`tryDenoise` (Thorough only) are what actually rescue blurred, creased,
|
||||
* low-contrast and photographed-off-a-screen codes.
|
||||
* - `maxNumberOfSymbols = MAX_SYMBOLS` so several codes in frame become a choice for the user
|
||||
* rather than a coin flip.
|
||||
*/
|
||||
class ZxingCppBarcodeDecoder : BarcodeDecoder {
|
||||
private val fast = BarcodeReader(options(tryHarder = false))
|
||||
private val thorough = BarcodeReader(options(tryHarder = true))
|
||||
|
||||
private fun reader(effort: DecodeEffort) = if (effort == DecodeEffort.Fast) fast else thorough
|
||||
|
||||
override fun decode(
|
||||
image: ImageProxy,
|
||||
effort: DecodeEffort,
|
||||
): List<ScanResult> =
|
||||
try {
|
||||
reader(effort).read(image).toScanResults()
|
||||
} catch (e: IllegalStateException) {
|
||||
// read() rejects any format that is not YUV. We always request YUV_420_888, so this
|
||||
// means the device handed us something else; log once per frame rather than crash.
|
||||
Log.w("QrScanner") { "Unsupported analysis image format ${image.format}: ${e.message}" }
|
||||
emptyList()
|
||||
}
|
||||
|
||||
override fun decode(
|
||||
bitmap: Bitmap,
|
||||
effort: DecodeEffort,
|
||||
): List<ScanResult> = reader(effort).read(bitmap, Rect(0, 0, bitmap.width, bitmap.height)).toScanResults()
|
||||
|
||||
private fun List<BarcodeReader.Result>.toScanResults(): List<ScanResult> =
|
||||
mapNotNull { result ->
|
||||
val text = result.text
|
||||
if (result.error != null || text.isNullOrEmpty()) return@mapNotNull null
|
||||
|
||||
ScanResult(
|
||||
text = text,
|
||||
bounds =
|
||||
result.position.let {
|
||||
ScanBounds(
|
||||
topLeft = ScanPoint(it.topLeft.x.toFloat(), it.topLeft.y.toFloat()),
|
||||
topRight = ScanPoint(it.topRight.x.toFloat(), it.topRight.y.toFloat()),
|
||||
bottomRight = ScanPoint(it.bottomRight.x.toFloat(), it.bottomRight.y.toFloat()),
|
||||
bottomLeft = ScanPoint(it.bottomLeft.x.toFloat(), it.bottomLeft.y.toFloat()),
|
||||
)
|
||||
},
|
||||
sequenceId = result.sequenceId,
|
||||
sequenceIndex = result.sequenceIndex,
|
||||
sequenceSize = result.sequenceSize,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Enough to disambiguate a poster with a few codes on it without letting a page of
|
||||
* barcodes turn every frame into a long detection run.
|
||||
*/
|
||||
const val MAX_SYMBOLS = 5
|
||||
|
||||
/**
|
||||
* Only the square formats. Nostr uses plain QR, but Micro and rMQR cost nothing extra to
|
||||
* accept and some hardware wallets and printed tags use them. Linear barcodes stay off:
|
||||
* they have no meaning here and each extra family slows every frame down.
|
||||
*/
|
||||
private val FORMATS =
|
||||
setOf(
|
||||
BarcodeReader.Format.QR_CODE,
|
||||
BarcodeReader.Format.MICRO_QR_CODE,
|
||||
BarcodeReader.Format.RMQR_CODE,
|
||||
)
|
||||
|
||||
private fun options(tryHarder: Boolean) =
|
||||
BarcodeReader.Options(
|
||||
formats = FORMATS,
|
||||
tryHarder = tryHarder,
|
||||
tryRotate = true,
|
||||
tryInvert = true,
|
||||
tryDownscale = true,
|
||||
tryDenoise = tryHarder,
|
||||
binarizer = BarcodeReader.Binarizer.LOCAL_AVERAGE,
|
||||
maxNumberOfSymbols = MAX_SYMBOLS,
|
||||
textMode = BarcodeReader.TextMode.PLAIN,
|
||||
)
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner
|
||||
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlin.math.max
|
||||
|
||||
/** One analysed frame: what was decoded, how big the analysed image was, and how dark it is. */
|
||||
data class FrameScan(
|
||||
val results: List<ScanResult>,
|
||||
val frame: ScanFrame,
|
||||
/** Mean luminance over a sparse sample of the Y plane, 0f (black) to 1f (white). */
|
||||
val brightness: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Decodes every camera frame and reports what it found.
|
||||
*
|
||||
* Two things happen per frame beyond the decode itself:
|
||||
*
|
||||
* 1. **Effort alternates.** Most frames get a fast pass; every [THOROUGH_EVERY]th gets
|
||||
* `tryHarder` + `tryDenoise`. A stubborn code therefore still gets several expensive attempts
|
||||
* a second, without the frame rate collapsing for codes that were never difficult.
|
||||
* 2. **Brightness is measured.** A sparse sample of the Y plane costs almost nothing and is what
|
||||
* lets the UI offer the torch exactly when the scene is too dark, rather than parking a
|
||||
* permanent button in the corner or firing the light at people unprompted.
|
||||
*/
|
||||
class QrFrameAnalyzer(
|
||||
private val decoder: BarcodeDecoder,
|
||||
private val onFrame: (FrameScan) -> Unit,
|
||||
) : ImageAnalysis.Analyzer {
|
||||
private var frameCount = 0L
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
image.use {
|
||||
val effort =
|
||||
if (frameCount++ % THOROUGH_EVERY == 0L) DecodeEffort.Thorough else DecodeEffort.Fast
|
||||
|
||||
val brightness = meanLuminance(image)
|
||||
|
||||
val results =
|
||||
try {
|
||||
decoder.decode(image, effort)
|
||||
} catch (e: Exception) {
|
||||
// A frame we cannot read is not worth killing the camera over.
|
||||
Log.w("QrScanner") { "Decode failed on one frame: ${e.message}" }
|
||||
emptyList()
|
||||
}
|
||||
|
||||
onFrame(FrameScan(results, rotatedFrameSize(image), brightness))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The size of the image the decoder actually saw.
|
||||
*
|
||||
* zxing-cpp is handed the crop rect and the rotation, and reports positions inside that
|
||||
* cropped, rotated space — so a quarter-turn swaps width and height. The overlay maps
|
||||
* [ScanBounds] onto the preview with this, so getting it wrong draws the highlight in the
|
||||
* wrong place.
|
||||
*/
|
||||
private fun rotatedFrameSize(image: ImageProxy): ScanFrame {
|
||||
val crop = image.cropRect
|
||||
val quarterTurned = image.imageInfo.rotationDegrees % 180 != 0
|
||||
return if (quarterTurned) {
|
||||
ScanFrame(crop.height(), crop.width())
|
||||
} else {
|
||||
ScanFrame(crop.width(), crop.height())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean luminance over a grid of at most [LUMA_SAMPLES_PER_AXIS]² pixels.
|
||||
*
|
||||
* Uses absolute [java.nio.ByteBuffer.get] so it never disturbs the buffer position the
|
||||
* decoder is about to read from.
|
||||
*/
|
||||
private fun meanLuminance(image: ImageProxy): Float {
|
||||
val plane = image.planes.firstOrNull() ?: return 1f
|
||||
val buffer = plane.buffer
|
||||
val rowStride = plane.rowStride
|
||||
val crop = image.cropRect
|
||||
|
||||
val stepX = max(1, crop.width() / LUMA_SAMPLES_PER_AXIS)
|
||||
val stepY = max(1, crop.height() / LUMA_SAMPLES_PER_AXIS)
|
||||
|
||||
var sum = 0L
|
||||
var count = 0
|
||||
var y = crop.top
|
||||
while (y < crop.bottom) {
|
||||
val row = y * rowStride
|
||||
var x = crop.left
|
||||
while (x < crop.right) {
|
||||
val index = row + x
|
||||
if (index in 0 until buffer.limit()) {
|
||||
sum += buffer.get(index).toInt() and 0xFF
|
||||
count++
|
||||
}
|
||||
x += stepX
|
||||
}
|
||||
y += stepY
|
||||
}
|
||||
|
||||
return if (count == 0) 1f else sum.toFloat() / count / 255f
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* At ~30fps this is roughly six thorough passes a second — enough that a hard code
|
||||
* resolves in well under a second, few enough that the analysis thread keeps up.
|
||||
*/
|
||||
const val THOROUGH_EVERY = 5L
|
||||
|
||||
private const val LUMA_SAMPLES_PER_AXIS = 24
|
||||
}
|
||||
}
|
||||
+171
@@ -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<ScanResult> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val bounds = readBounds(context, uri) ?: return@withContext emptyList()
|
||||
val longestEdge = max(bounds.outWidth, bounds.outHeight)
|
||||
if (longestEdge <= 0) return@withContext emptyList()
|
||||
|
||||
// Three passes, cheapest first. A code that a downscaled pass misses because its
|
||||
// modules blurred together often survives at full resolution, and a code in a small
|
||||
// thumbnail often needs *more* pixels per module than it shipped with.
|
||||
val sampleSizes =
|
||||
buildList {
|
||||
add(sampleSizeFor(longestEdge, FIRST_PASS_MAX_EDGE))
|
||||
if (sampleSizeFor(longestEdge, FIRST_PASS_MAX_EDGE) != 1) add(1)
|
||||
}
|
||||
|
||||
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<ScanResult> {
|
||||
val original = loadBitmap(context, uri, sampleSize) ?: return emptyList()
|
||||
var upscaled: Bitmap? = null
|
||||
return try {
|
||||
if (upscale) upscaled = original.scale(original.width * 2, original.height * 2)
|
||||
decoder.decode(upscaled ?: original, DecodeEffort.Thorough)
|
||||
} catch (e: 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
|
||||
}
|
||||
}
|
||||
+610
@@ -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<FrameScan>(Channel.CONFLATED) }
|
||||
|
||||
val previewView =
|
||||
remember {
|
||||
PreviewView(context).apply {
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
}
|
||||
}
|
||||
|
||||
var camera by remember { mutableStateOf<Camera?>(null) }
|
||||
var provider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||
var viewSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var focusRing by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
analysisExecutor.shutdown()
|
||||
frames.close()
|
||||
runCatching { provider?.unbindAll() }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(decoder) {
|
||||
if (decoder == null) state.notice = decoderUnavailable
|
||||
}
|
||||
|
||||
// Bind once the preview has a size: ViewPort needs a laid-out view, and binding both use
|
||||
// cases under one viewport is what makes the overlay's coordinate mapping exact.
|
||||
LaunchedEffect(decoder, viewSize) {
|
||||
if (decoder == null || viewSize == IntSize.Zero) return@LaunchedEffect
|
||||
|
||||
val cameraProvider =
|
||||
try {
|
||||
ProcessCameraProvider.awaitInstance(context)
|
||||
} catch (e: Exception) {
|
||||
Log.w("QrScanner", "Camera provider unavailable", e)
|
||||
state.notice = decoderUnavailable
|
||||
return@LaunchedEffect
|
||||
}
|
||||
provider = cameraProvider
|
||||
|
||||
val preview = Preview.Builder().build().apply { setSurfaceProvider(previewView.surfaceProvider) }
|
||||
|
||||
val analysis =
|
||||
ImageAnalysis
|
||||
.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
|
||||
.setResolutionSelector(ANALYSIS_RESOLUTION)
|
||||
.build()
|
||||
.apply {
|
||||
setAnalyzer(analysisExecutor, QrFrameAnalyzer(decoder) { frames.trySend(it) })
|
||||
}
|
||||
|
||||
val group =
|
||||
UseCaseGroup
|
||||
.Builder()
|
||||
.addUseCase(preview)
|
||||
.addUseCase(analysis)
|
||||
.apply { previewView.viewPort?.let { setViewPort(it) } }
|
||||
.build()
|
||||
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
camera =
|
||||
cameraProvider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, group).also {
|
||||
state.torchAvailable = it.cameraInfo.hasFlashUnit()
|
||||
state.maxZoomRatio = it.cameraInfo.zoomState.value
|
||||
?.maxZoomRatio ?: 1f
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("QrScanner", "Could not bind the camera", e)
|
||||
state.notice = decoderUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
for (scan in frames) {
|
||||
state.onFrame(scan, SystemClock.elapsedRealtime())?.let(submit)
|
||||
}
|
||||
}
|
||||
|
||||
// One writer each for torch and zoom, driven off state rather than from the gesture handlers,
|
||||
// so the auto-zoom sweep and a pinch cannot fight over the camera control.
|
||||
LaunchedEffect(camera) {
|
||||
val control = camera?.cameraControl ?: return@LaunchedEffect
|
||||
snapshotFlow { state.torchOn }.collect { runCatching { control.enableTorch(it) } }
|
||||
}
|
||||
|
||||
LaunchedEffect(camera) {
|
||||
val control = camera?.cameraControl ?: return@LaunchedEffect
|
||||
snapshotFlow { state.zoomRatio }.collect { runCatching { control.setZoomRatio(it) } }
|
||||
}
|
||||
|
||||
AutoZoomSweep(state = state, enabled = camera != null)
|
||||
|
||||
LaunchedEffect(state.notice) {
|
||||
if (state.notice != null) {
|
||||
delay(NOTICE_DURATION_MS)
|
||||
state.notice = null
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(focusRing) {
|
||||
if (focusRing != null) {
|
||||
delay(FOCUS_RING_DURATION_MS)
|
||||
focusRing = null
|
||||
}
|
||||
}
|
||||
|
||||
val pickImage =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
||||
if (uri == null || decoder == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
val found = QrImageImport.decode(context, uri, decoder).firstOrNull()?.text
|
||||
if (found == null) state.notice = noCodeInImage else submit(found)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { viewSize = it }
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, _, zoom, _ ->
|
||||
if (zoom != 1f) {
|
||||
state.onPinch()
|
||||
state.zoomRatio = (state.zoomRatio * zoom).coerceIn(1f, maxOf(1f, state.maxZoomRatio))
|
||||
}
|
||||
}
|
||||
}.pointerInput(Unit) {
|
||||
detectTapGestures { tap ->
|
||||
val picked = pickCandidateAt(tap, state, viewSize)
|
||||
if (picked != null) {
|
||||
state.onCandidateTapped(picked, SystemClock.elapsedRealtime())?.let(submit)
|
||||
} else {
|
||||
focusRing = tap
|
||||
focusAt(previewView, camera, tap)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
||||
|
||||
ScanOverlayCanvas(state = state, viewSize = viewSize, focusRing = focusRing)
|
||||
|
||||
Text(
|
||||
text = stringRes(Res.string.point_to_the_qr_code),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 96.dp).fillMaxWidth(0.8f),
|
||||
)
|
||||
|
||||
QrScannerControls(
|
||||
state = state,
|
||||
onClose = onDismiss,
|
||||
onToggleTorch = { state.torchOn = !state.torchOn },
|
||||
onPickImage = { pickImage.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
||||
onPaste = {
|
||||
pasteFromClipboard(
|
||||
context = context,
|
||||
decoder = decoder,
|
||||
onText = submit,
|
||||
onImage = { uri ->
|
||||
if (decoder != null) {
|
||||
scope.launch {
|
||||
val found = QrImageImport.decode(context, uri, decoder).firstOrNull()?.text
|
||||
if (found == null) state.notice = noCodeInImage else submit(found)
|
||||
}
|
||||
}
|
||||
},
|
||||
onEmpty = { state.notice = clipboardEmpty },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
state.rejected?.let { payload ->
|
||||
ScanOutcomeSheet(
|
||||
payload = payload,
|
||||
onDismiss = { state.dismissRejection() },
|
||||
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
|
||||
+309
@@ -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))
|
||||
}
|
||||
}
|
||||
+212
@@ -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<List<ScanResult>>(emptyList())
|
||||
private set
|
||||
|
||||
/** Set while a Structured Append payload is half-captured: captured count to total. */
|
||||
var sequenceProgress by mutableStateOf<Pair<Int, Int>?>(null)
|
||||
private set
|
||||
|
||||
/** True when the scene has been too dark for [DARK_DWELL_MS]; the UI offers the torch. */
|
||||
var isDark by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var torchOn by mutableStateOf(false)
|
||||
var torchAvailable by mutableStateOf(false)
|
||||
|
||||
var zoomRatio by mutableFloatStateOf(1f)
|
||||
var maxZoomRatio by mutableFloatStateOf(1f)
|
||||
|
||||
/**
|
||||
* Auto-zoom stops for good the first time the user pinches. Someone who has framed the shot
|
||||
* themselves does not want the camera arguing about it.
|
||||
*/
|
||||
var autoZoomEnabled by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
/** A payload we decoded but the caller could not use; drives the explain-what-happened sheet. */
|
||||
var rejected by mutableStateOf<ScannedPayload?>(null)
|
||||
|
||||
/** A transient message (no code in that picture, empty clipboard, decoder unavailable). */
|
||||
var notice by mutableStateOf<String?>(null)
|
||||
|
||||
private var lastSubmittedText: String? = null
|
||||
private var lastSubmittedAt = 0L
|
||||
private var darkSinceMs = 0L
|
||||
|
||||
/** Milliseconds since anything at all was decoded — what auto-zoom watches. */
|
||||
var msSinceLastDetection by 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
|
||||
}
|
||||
}
|
||||
+145
@@ -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 ""
|
||||
+85
@@ -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,
|
||||
)
|
||||
+159
@@ -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)
|
||||
}
|
||||
+94
@@ -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<Int, String>()
|
||||
|
||||
/** Parts captured so far for the sequence in progress. */
|
||||
val captured: Int get() = parts.size
|
||||
|
||||
/** How many parts the sequence in progress needs in total, or 0 when idle. */
|
||||
val total: Int get() = expected
|
||||
|
||||
/**
|
||||
* Feeds one decoded part in.
|
||||
*
|
||||
* Returns the joined payload once every part has been seen, or null while the sequence is
|
||||
* still incomplete. A part from a different sequence, or one arriving after [timeoutMs] of
|
||||
* silence, restarts the accumulation rather than corrupting it — someone who gives up
|
||||
* halfway and points the camera at a different code should not get a splice of the two.
|
||||
*/
|
||||
fun add(
|
||||
result: ScanResult,
|
||||
nowMs: Long,
|
||||
): String? {
|
||||
if (!result.isPartOfSequence) return null
|
||||
|
||||
val id = result.sequenceId
|
||||
val stale = nowMs - lastUpdateMs > timeoutMs
|
||||
if (id != sequenceId || expected != result.sequenceSize || stale) {
|
||||
reset()
|
||||
sequenceId = id
|
||||
expected = result.sequenceSize
|
||||
}
|
||||
|
||||
lastUpdateMs = nowMs
|
||||
parts[result.sequenceIndex] = result.text
|
||||
|
||||
if (parts.size < expected) return null
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+139
@@ -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()
|
||||
}
|
||||
}
|
||||
+113
@@ -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)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -199,6 +199,35 @@
|
||||
</plurals>
|
||||
<string name="profile_banner">Profile Banner</string>
|
||||
<string name="point_to_the_qr_code">Point to the QR Code</string>
|
||||
<string name="qr_scanner_camera_rationale">Amethyst needs the camera to scan QR codes. Nothing is recorded or uploaded — frames are decoded on the device and discarded.</string>
|
||||
<string name="qr_scanner_grant_camera">Allow camera</string>
|
||||
<string name="qr_scanner_camera_blocked">Camera access is turned off for Amethyst. You can turn it back on in Android settings.</string>
|
||||
<string name="qr_scanner_open_settings">Open settings</string>
|
||||
<string name="qr_scanner_unavailable">The QR decoder could not start on this device. You can still paste the code as text.</string>
|
||||
<string name="qr_scanner_torch_on">Turn on the light</string>
|
||||
<string name="qr_scanner_torch_off">Turn off the light</string>
|
||||
<string name="qr_scanner_dark_hint">It is dark — try the light</string>
|
||||
<string name="qr_scanner_scan_image">Scan a picture</string>
|
||||
<string name="qr_scanner_paste">Paste</string>
|
||||
<string name="qr_scanner_no_code_in_image">No QR code found in that picture</string>
|
||||
<string name="qr_scanner_clipboard_empty">Nothing on the clipboard to scan</string>
|
||||
<string name="qr_scanner_pick_one">Tap the code you want</string>
|
||||
<string name="qr_scanner_sequence_progress">Captured %1$s of %2$s parts — keep going</string>
|
||||
<string name="qr_scanner_zoom_reset">Reset zoom</string>
|
||||
<string name="qr_scanner_unsupported_title">Amethyst can\'t open this code</string>
|
||||
<string name="qr_scanner_unsupported_secret">This code carries a private key or a pairing secret, so its contents are not shown here. Use the screen that expects it.</string>
|
||||
<string name="qr_scanner_kind_nsec">This is a private key. Paste it on the login screen instead.</string>
|
||||
<string name="qr_scanner_kind_wallet">This is a wallet connection. Add it from Wallet settings.</string>
|
||||
<string name="qr_scanner_kind_signer">This is a remote signer. Add it from the NIP-46 signer screen.</string>
|
||||
<string name="qr_scanner_kind_lightning">This is a Lightning invoice.</string>
|
||||
<string name="qr_scanner_kind_cashu">This is a Cashu token.</string>
|
||||
<string name="qr_scanner_kind_web">This is a web link.</string>
|
||||
<string name="qr_scanner_kind_text">This is plain text, not a Nostr code.</string>
|
||||
<string name="qr_scanner_kind_nostr_unsupported">This is a Nostr code, but not one this screen can open.</string>
|
||||
<string name="qr_scanner_open_link">Open link</string>
|
||||
<string name="qr_scanner_copy">Copy</string>
|
||||
<string name="qr_scanner_copied">Copied</string>
|
||||
<string name="qr_scanner_try_again">Scan again</string>
|
||||
<string name="show_qr">Show QR</string>
|
||||
<string name="profile_image">Profile Picture</string>
|
||||
<string name="your_profile_image">Your Profile Picture</string>
|
||||
|
||||
+3
@@ -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")
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user