diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index ec66eddf6a..6f61db3baf 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -50,6 +50,35 @@ sealed interface DeeplinkType: Parcelable { val code: String, val origin: String? = null ): DeeplinkType, Navigatable + + /** + * Whether this route may be produced by scanning rather than by tapping a link. + * + * An allowlist, stated positively and exhaustively, because the cost of a mistake is + * one-sided: [Login] carries the account seed and [EmailVerification] carries a verification + * secret, and a scanned image is something somebody else can put in front of the camera or + * send as a photo. A new route type is refused until someone adds it here, which is the + * property an `else -> false` would not have. + * + * [GroupChatInvite] is false because no scan path opens one -- `Scanner`'s `Navigatable` + * branch has no case for it. It is a gap named rather than a risk. + * + * iOS's equivalent is `ScanViewModel.canScanQR`. + */ + val isScannable: Boolean + get() = when (this) { + is CashLink, + is TokenInfo, + is TipChat, + is Tipcard, + is TipcardByUsername, + -> true + + is Login, + is EmailVerification, + is GroupChatInvite, + -> false + } } val Uri.fragments: Map diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b6d122b2c6..4cc36b6dbf 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -829,6 +829,7 @@ Deposit Other Flipcash Currencies Learn more + Scan a code from a photo Leaderboard Ranking Members must have a minimum balance of %1$s to be counted diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt new file mode 100644 index 0000000000..f8ebfd13fb --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt @@ -0,0 +1,55 @@ +package com.flipcash.app.core.navigation + +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.services.models.chat.ChatId +import com.getcode.solana.keys.Mint +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The allowlist that decides what a scan is allowed to produce. + * + * A tapped link is something the user went and found; a scanned image is something somebody else + * can put in front of the camera or send as a photo. The two deserve different answers, and this + * pins which. + */ +class DeeplinkScanPolicyTest { + + @Test + fun `only payment routes are scannable`() { + assertTrue(DeeplinkType.CashLink(ENTROPY).isScannable) + assertTrue(DeeplinkType.TokenInfo(MINT).isScannable) + assertTrue(DeeplinkType.TipChat(ChatIdentifier.ByChatId(CHAT_ID)).isScannable) + assertTrue(DeeplinkType.Tipcard(USER_ID).isScannable) + assertTrue(DeeplinkType.TipcardByUsername(USERNAME).isScannable) + } + + @Test + fun `credential routes are never scannable`() { + assertFalse(DeeplinkType.Login(ENTROPY).isScannable) + assertFalse(DeeplinkType.EmailVerification(EMAIL, CODE).isScannable) + } + + /** + * Not a security judgement -- an invite is harmless. It is that no scan path has ever opened + * one: `Scanner`'s `Navigatable` branch has no case for it and falls to `emptyList()`. Naming + * it here keeps that a stated decision rather than a gap, and flipping it means adding the + * destination too. + */ + @Test + fun `a group chat invite is not a scan destination`() { + assertFalse(DeeplinkType.GroupChatInvite(CHAT_ID).isScannable) + } + + private companion object { + const val ENTROPY = "0123456789abcdef" + const val USERNAME = "someone" + const val EMAIL = "someone@example.com" + const val CODE = "123456" + + val USER_ID: List = List(16) { it.toByte() } + val MINT = Mint(List(32) { it.toByte() }) + val CHAT_ID = ChatId(ByteArray(16) { it.toByte() }) + } +} diff --git a/apps/flipcash/features/scanner/build.gradle.kts b/apps/flipcash/features/scanner/build.gradle.kts index b027282036..812002f78e 100644 --- a/apps/flipcash/features/scanner/build.gradle.kts +++ b/apps/flipcash/features/scanner/build.gradle.kts @@ -20,9 +20,16 @@ dependencies { implementation(project(":libs:datetime")) implementation(project(":libs:messaging")) implementation(project(":libs:permissions:bindings")) + implementation(project(":libs:quickresponse")) implementation(project(":libs:vibrator:bindings")) implementation(project(":ui:biometrics")) implementation(project(":ui:scanner")) + // Declared rather than inherited through `:ui:scanner`'s `api`: this module names + // `KikCodeScannerImpl`, `StaticImageAnalyzerImpl` and `KikCodeAnalyzer` directly. + implementation(project(":vendor:kik:scanner")) implementation(libs.androidx.camerax.view) implementation(libs.androidx.foundation.layout) + // `ui:components` keeps haze to itself, but `CircularIconButton` takes a `HazeState`, so a + // caller wanting the frosted fill has to be able to name the type. + implementation(libs.bundles.haze) } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/GalleryScanButton.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/GalleryScanButton.kt new file mode 100644 index 0000000000..e8bd0de186 --- /dev/null +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/GalleryScanButton.kt @@ -0,0 +1,66 @@ +package com.flipcash.app.scanner.internal + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PhotoLibrary +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.foundation.layout.size +import com.flipcash.features.scanner.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.CircularIconButton +import dev.chrisbanes.haze.HazeState + +/** + * The Scan tab's gallery entry point, in the scanner's top-right corner. + * + * `PickVisualMedia` runs out of process, so tapping this never asks for a permission -- which is + * what lets the glyph be a plain static icon rather than a permission prompt with an icon on it. + * The app declares no `READ_MEDIA_IMAGES` and does not want to start. + * + * It sits on a frosted circle rather than bare on the preview because the backdrop here is whatever + * the camera happens to see. A white glyph alone has no contrast it can rely on -- point the phone + * at a bright wall and it disappears. [hazeState] must be a source that does NOT contain this + * button, or the blur samples itself; [com.flipcash.app.scanner.internal.bills.ScannableContainer] + * is what arranges that. + */ +@Composable +internal fun GalleryScanButton( + onImagePicked: (Uri) -> Unit, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, +) { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> uri?.let(onImagePicked) } + + CircularIconButton( + modifier = modifier, + buttonSize = CodeTheme.dimens.staticGrid.x10, + imageSize = CodeTheme.dimens.staticGrid.x5, + hazeState = hazeState, + testTag = "scanner_gallery_button", + onClick = { + launcher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + ) { size: Dp -> + Icon( + // Outline rather than filled: filled is the weight the nav bar gives a *selected* tab, + // and this is an action with no selected state to claim. Nothing on this screen is + // filled, so the outline is also what matches the surface it sits on. + imageVector = Icons.Outlined.PhotoLibrary, + contentDescription = stringResource(R.string.content_description_scanFromGallery), + tint = Color.White, + modifier = Modifier.size(size), + ) + } +} diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index 75c3ee410b..c4e94487e1 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -1,15 +1,26 @@ package com.flipcash.app.scanner.internal import android.annotation.SuppressLint +import android.net.Uri +import androidx.compose.foundation.background +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.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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute @@ -19,18 +30,36 @@ import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.app.router.LocalRouter import com.flipcash.app.scanner.internal.bills.ScannableContainer +import com.flipcash.app.session.CodeScanEvent import com.flipcash.app.session.LocalSessionController import com.flipcash.app.session.TipCardEvent +import com.flipcash.features.scanner.R import com.getcode.libs.code.detection.CodeScanResult +import com.getcode.libs.qr.StaticQrAnalyzer +import com.getcode.manager.TopBarManager +import com.getcode.media.StaticImageAnalyzerImpl +import com.getcode.media.StaticImageResult import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.Black50 +import com.getcode.theme.CodeTheme import com.getcode.ui.biometrics.LocalBiometricsState +import com.getcode.ui.core.noRippleClickable import com.getcode.ui.scanner.CodeScanner import com.getcode.ui.scanner.NoCamerasAvailableException +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.utils.KeepScreenOn import com.getcode.util.vibration.LocalVibrator import com.getcode.utils.ErrorUtils +import com.kik.kikx.kikcodes.implementation.KikCodeAnalyzer import com.kik.kikx.kikcodes.implementation.KikCodeResult +import com.kik.kikx.kikcodes.implementation.KikCodeScannerImpl import dev.theolm.rinku.DeepLink +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicReference +import kotlin.time.TimeSource @Composable internal fun Scanner() { @@ -49,10 +78,127 @@ internal fun Scanner() { } val context = LocalContext.current + val resources = LocalResources.current val focusManager = LocalFocusManager.current val biometricsState = LocalBiometricsState.current val vibrator = LocalVibrator.current + val scope = rememberCoroutineScope() + + // Built by hand for the same reason `rememberMultiCodeAnalyzer` builds the camera's: nothing + // binds these types, and neither the module that declares them nor `:ui:scanner` is + // annotation-processed, so there is no graph to ask. + val kikCodeAnalyzer = remember(context) { + val scanner = KikCodeScannerImpl() + KikCodeAnalyzer(scanner, StaticImageAnalyzerImpl(context.applicationContext, scanner)) + } + val staticQrAnalyzer = remember(context) { StaticQrAnalyzer(context.applicationContext) } + + var isScanningStillImage by remember { mutableStateOf(false) } + + // Not snapshot state: nothing drawn reads it, and making it state would recompose the whole + // scanner on every write for a value only the cancel lambda looks at. + val scanJob = remember { AtomicReference(null) } + + val onDeeplinkScanned = { deeplink: DeeplinkType -> + vibrator.vibrate(duration = 50) + when (deeplink) { + is DeeplinkType.CashLink -> { + session.openCashLink(deeplink.entropy) + } + is DeeplinkType.Navigatable -> { + val routes: List = when (deeplink) { + is DeeplinkType.TokenInfo -> listOf( + AppRoute.Sheets.Wallet, + Info(deeplink.mint, fromDeeplink = true) + ) + // Scanned tip-DM code — same destination as the + // /tip/chat/{id} deeplink. + is DeeplinkType.TipChat -> listOf( + AppRoute.Sheets.Tips(), + AppRoute.Messaging.Chat(deeplink.identifier), + ) + else -> emptyList() + } + if (routes.isNotEmpty()) { + navigator.navigateAll(routes) + } + } + is DeeplinkType.Login -> Unit + is DeeplinkType.Tipcard -> { + session.resolveTipCard(TipCardOwner.ById(deeplink.userId)) + } + // A printed or on-screen `flipcash.com/{username}` is the + // same card as a scanned `flipcash.com/{id}`, addressed by + // handle. + is DeeplinkType.TipcardByUsername -> { + session.resolveTipCard(TipCardOwner.ByUsername(deeplink.username)) + } + } + } + + // Stated once and by name. The branches above still refuse credential routes on their own, + // but that refusal used to be an emergent property of two `when`s in different places. + val firstScannableDeeplink = { urls: List -> + urls.firstNotNullOfOrNull { url -> + val type = router.classify(DeepLink(url)) + analytics.deeplinkParsed(type, url) + type + }?.takeIf { it.isScannable } + } + + val showNoCodeFound = { + TopBarManager.showMessage( + title = resources.getString(R.string.error_title_noCodeFound), + message = resources.getString(R.string.error_description_noCodeFound), + ) + } + + val onImagePicked = { uri: Uri -> + scanJob.getAndSet(null)?.cancel() + analytics.galleryImagePicked() + scanJob.set(scope.launch { + isScanningStillImage = true + val started = TimeSource.Monotonic.markNow() + try { + when (val result = kikCodeAnalyzer.detect(uri)) { + is StaticImageResult.Found -> { + analytics.galleryScanSucceeded( + tier = result.tier, + zoom = result.zoom, + timeMillis = started.elapsedNow().inWholeMilliseconds, + ) + // No dedup to get past: `CodeScanDelegate` suppresses a rendezvous only + // once a grab has succeeded, and clears it again on failure, so picking + // the same photo twice is refused for the same reason pointing the camera + // at a spent code is. + session.onCodeScan(result.code, fromStillImage = true) + } + // A QR route that fails `isScannable` reads as "no code": someone sent a login + // link learns nothing from being told why it was refused. + StaticImageResult.NotFound, + StaticImageResult.Exhausted, + -> { + val deeplink = firstScannableDeeplink(staticQrAnalyzer.detect(uri)) + if (deeplink != null) { + onDeeplinkScanned(deeplink) + } else { + // Reported after the QR fallback, so the event counts searches that + // found nothing at all rather than searches the ladder missed. + analytics.galleryScanFailed( + timeMillis = started.elapsedNow().inWholeMilliseconds, + exhausted = result is StaticImageResult.Exhausted, + ) + showNoCodeFound() + } + } + } + } finally { + isScanningStillImage = false + } + }) + Unit + } // Scanning your own tip card resolves to nothing to pay, so send the user to the You tab — // the surface that owns their card — rather than leaving the scan with no visible outcome. @@ -67,6 +213,19 @@ internal fun Scanner() { } } + LaunchedEffect(session) { + session.codeScanEvents.collect { event -> + when (event) { + // Only a still-image scan raises this; the session gates it on the origin of + // the scan rather than the scanner holding a flag of its own. + CodeScanEvent.CashCodeNotLive -> TopBarManager.showMessage( + title = resources.getString(R.string.error_title_cashExpired), + message = resources.getString(R.string.error_description_cashExpired), + ) + } + } + } + LaunchedEffect(biometricsState, previewing) { if (previewing == true) { focusManager.clearFocus() @@ -79,77 +238,43 @@ internal fun Scanner() { } } - @SuppressLint("LocalContextGetResourceValueCall") - ScannableContainer( - scannerView = { - CodeScanner( - scanningEnabled = previewing == true, - cameraGesturesEnabled = true, - onPinchStateChanged = { _, _ -> }, - onPreviewStateChanged = { - cameraAvailable = true - previewing = it - }, - onCodeScanned = { result -> - when (result) { - is CodeScanResult.QrCode -> { - val urls = result.results - val deeplink = urls.firstNotNullOfOrNull { url -> - val type = router.classify(DeepLink(url)) - analytics.deeplinkParsed(type, url) - type + Box(modifier = Modifier.fillMaxSize()) { + @SuppressLint("LocalContextGetResourceValueCall") + ScannableContainer( + onImagePicked = onImagePicked, + scannerView = { + CodeScanner( + scanningEnabled = previewing == true, + cameraGesturesEnabled = true, + onPinchStateChanged = { _, _ -> }, + onPreviewStateChanged = { + cameraAvailable = true + previewing = it + }, + onCodeScanned = { result -> + when (result) { + is CodeScanResult.QrCode -> { + firstScannableDeeplink(result.results)?.let(onDeeplinkScanned) } - if (deeplink != null) { - vibrator.vibrate(duration = 50) - when (deeplink) { - is DeeplinkType.CashLink -> { - session.openCashLink(deeplink.entropy) - } - is DeeplinkType.Navigatable -> { - val routes: List = when (deeplink) { - is DeeplinkType.TokenInfo -> listOf( - AppRoute.Sheets.Wallet, - Info(deeplink.mint, fromDeeplink = true) - ) - // Scanned tip-DM code — same destination as the - // /tip/chat/{id} deeplink. - is DeeplinkType.TipChat -> listOf( - AppRoute.Sheets.Tips(), - AppRoute.Messaging.Chat(deeplink.identifier), - ) - else -> emptyList() - } - if (routes.isNotEmpty()) { - navigator.navigateAll(routes) - } - } - is DeeplinkType.Login -> Unit - is DeeplinkType.Tipcard -> { - session.resolveTipCard(TipCardOwner.ById(deeplink.userId)) - } - // A printed or on-screen `flipcash.com/{username}` is the - // same card as a scanned `flipcash.com/{id}`, addressed by - // handle. - is DeeplinkType.TipcardByUsername -> { - session.resolveTipCard(TipCardOwner.ByUsername(deeplink.username)) - } - } + is KikCodeResult -> { + session.onCodeScan(result.kikCode) } } - is KikCodeResult -> { - session.onCodeScan(result.kikCode) + }, + onError = { + if (it is NoCamerasAvailableException) { + cameraAvailable = false } + ErrorUtils.handleError(it) } - }, - onError = { - if (it is NoCamerasAvailableException) { - cameraAvailable = false - } - ErrorUtils.handleError(it) - } - ) - }, - ) + ) + }, + ) + + if (isScanningStillImage) { + StillImageScanOverlay(onCancel = { scanJob.getAndSet(null)?.cancel() }) + } + } DisposableEffect(LocalCodeNavigator.current) { onDispose { @@ -167,8 +292,40 @@ internal fun Scanner() { } } + // One instance rather than two: a second `KeepScreenOn` leaving composition clears the window + // flag the first one set. Brightness is boosted only for a presented bill, which is the case + // it exists for — a still-image search shows nothing that has to be scannable. KeepScreenOn( - isEnabled = billState.bill != null, - useBrightness = true, + isEnabled = billState.bill != null || isScanningStillImage, + useBrightness = billState.bill != null, ) } + +/** + * Covers the scanner while a picked image is searched. + * + * Blocking on purpose: the ladder runs for seconds, and a camera the user can still drive + * underneath a search they cannot see the end of is two scanners at once. + */ +@Composable +private fun StillImageScanOverlay(onCancel: () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Black50) + .noRippleClickable { }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x6), + ) { + CodeCircularProgressIndicator() + CodeButton( + text = stringResource(R.string.action_cancel), + buttonState = ButtonState.Filled50, + onClick = onCancel, + ) + } + } +} diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt index 65e2612735..0f75c0acb7 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt @@ -1,40 +1,50 @@ package com.flipcash.app.scanner.internal.bills +import android.net.Uri import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag -import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.android.extensions.launchAppSettings +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.LocalFeatureFlags +import com.flipcash.app.scanner.internal.GalleryScanButton import com.flipcash.app.session.LocalSessionController import com.flipcash.app.updates.LocalAppUpdater import com.flipcash.features.scanner.R import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager +import com.getcode.theme.CodeTheme import com.getcode.ui.biometrics.LocalBiometricsState import com.getcode.ui.components.OnLifecycleEvent import com.getcode.ui.scanner.views.CameraDisabledView import com.getcode.ui.scanner.views.CameraPermissionsMissingView import com.getcode.util.permissions.PermissionResult import com.getcode.util.permissions.rememberCameraPermission +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState /** * The scanner surface: the camera preview (via [scannerView]) and its permission states. Bills are * not drawn here — a presented bill renders at the app root - * ([com.flipcash.app.bills.BillOverlay]) so it can appear over any screen. Scanner chrome is the - * hoisted app nav bar, not an in-screen HUD. + * ([com.flipcash.app.bills.BillOverlay]) so it can appear over any screen. The only in-screen + * chrome is the gallery button in the top-right corner; the tab bar is hoisted to the app root. */ @Composable internal fun ScannableContainer( + onImagePicked: (Uri) -> Unit, modifier: Modifier = Modifier, scannerView: @Composable () -> Unit, ) { @@ -65,6 +75,12 @@ internal fun ScannableContainer( } val state by session.state.collectAsStateWithLifecycle() + val billState by session.billState.collectAsStateWithLifecycle() + + // The app-root haze source is no use here: it wraps `AppNavHost`, and this screen is *inside* + // it, so a blur hung off it would be sampling a layer that contains itself. The scanner keeps + // its own source, scoped to the preview only, with the button deliberately outside it. + val hazeState = rememberHazeState() Box( modifier = Modifier @@ -74,36 +90,68 @@ internal fun ScannableContainer( ) { val availableUpdate by LocalAppUpdater.current.availableUpdate.collectAsStateWithLifecycle() - when { - LocalBiometricsState.current.isAwaitingAuthentication -> { - // waiting for result - } + Box(modifier = Modifier.fillMaxSize().hazeSource(hazeState)) { + when { + LocalBiometricsState.current.isAwaitingAuthentication -> { + // waiting for result + } - availableUpdate != null -> { - // waiting for update - } + availableUpdate != null -> { + // waiting for update + } - else -> { - when (cameraPermission.status) { - PermissionResult.Denied -> { - CameraDisabledView(modifier = Modifier.fillMaxSize()) { - cameraPermission.launch() + else -> { + when (cameraPermission.status) { + PermissionResult.Denied -> { + CameraDisabledView(modifier = Modifier.fillMaxSize()) { + cameraPermission.launch() + } } - } - PermissionResult.NotRequested -> { - CameraPermissionsMissingView( - modifier = Modifier.fillMaxSize(), - onClick = { cameraPermission.launch() } - ) - } - PermissionResult.Granted -> scannerView() - PermissionResult.PermanentlyDenied -> { - CameraDisabledView(modifier = Modifier.fillMaxSize()) { - context.launchAppSettings() + PermissionResult.NotRequested -> { + CameraPermissionsMissingView( + modifier = Modifier.fillMaxSize(), + onClick = { cameraPermission.launch() } + ) + } + PermissionResult.Granted -> scannerView() + PermissionResult.PermanentlyDenied -> { + CameraDisabledView(modifier = Modifier.fillMaxSize()) { + context.launchAppSettings() + } } } } } } + + // Remembered because `observe` builds a fresh `stateIn(dataScope, Eagerly, ..)` on every + // call and `dataScope` outlives the screen: calling it straight from a composable body + // starts a DataStore collection per recomposition that nothing ever cancels. + val featureFlags = LocalFeatureFlags.current + val galleryFlag = remember(featureFlags) { + featureFlags.observe(FeatureFlag.ScanFromGallery) + } + val galleryEnabled by galleryFlag.collectAsStateWithLifecycle() + + // Outside the permission `when` on purpose: a picked photo needs no camera, and someone + // who declined the camera is exactly who this is for. + // + // Hidden while a bill is up. `NavBillOverlayEntryDecorator` draws the bill after this + // entry's content, so the bill already covers the button -- but leaving it composed under + // the scrim means a live control is sitting behind something modal, which is worth not + // having rather than relying on the overlay to swallow every touch. + if (galleryEnabled && billState.bill == null) { + GalleryScanButton( + onImagePicked = onImagePicked, + hazeState = hazeState, + // Top-right, clear of the status bar. This corner rather than the bottom because + // the bottom belongs to the hoisted tab bar, and a control down there has to be + // positioned around a bar this screen does not own. + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(CodeTheme.dimens.inset) + ) + } } } diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt index a755f1d740..43cc0e10b6 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt @@ -39,6 +39,15 @@ interface FlipcashAnalyticsService : AnalyticsService { fun sell(mint: Mint, amount: Fiat, feeAmount: Fiat, error: Throwable? = null) fun messageSentInChat(type: ChatType, error: Throwable? = null) fun tipCardScanned() + + /** An image was picked from the gallery and the still-image search started. */ + fun galleryImagePicked() + + /** @param tier which rung of the crop ladder decoded, 1 to 3. */ + fun galleryScanSucceeded(tier: Int, zoom: Float, timeMillis: Long) + + /** @param exhausted true when the budget ran out rather than the ladder ending. */ + fun galleryScanFailed(timeMillis: Long, exhausted: Boolean) fun tipCardPresented() fun deeplinkOpened(url: String) fun deeplinkParsed(type: DeeplinkType?, url: String) @@ -139,6 +148,9 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun messageSentInChat(type: ChatType, error: Throwable?) = Unit override fun tipCardScanned() = Unit + override fun galleryImagePicked() = Unit + override fun galleryScanSucceeded(tier: Int, zoom: Float, timeMillis: Long) = Unit + override fun galleryScanFailed(timeMillis: Long, exhausted: Boolean) = Unit override fun tipCardPresented() = Unit override fun deeplinkOpened(url: String) = Unit diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt index 4fa4c2ac94..4b41386dac 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt @@ -191,6 +191,44 @@ internal sealed interface AnalyticsEvent { } } + /** + * The gallery scan path, end to end. + * + * [Succeeded] reports the tier and zoom that decoded and [Failed] the time a fruitless search + * took, which together are what would let the ladder's inherited constants be trimmed — they + * were carried over from Code and have never been measured against real screenshots. + */ + sealed interface GalleryScanEvent : AnalyticsEvent { + data object ImagePicked : GalleryScanEvent { + override val name = "Gallery Scan: Image Picked" + } + + data class Succeeded( + val tier: Int, + val zoom: Float, + val time: Long, + ) : GalleryScanEvent { + override val name = "Gallery Scan: Succeeded" + override fun toProperties() = mapOf( + "Tier" to tier.toString(), + "Zoom" to zoom.toString(), + "Time" to time.toString(), + ) + } + + data class Failed( + val time: Long, + /** True when the budget ran out rather than the ladder being walked to the end. */ + val exhausted: Boolean, + ) : GalleryScanEvent { + override val name = "Gallery Scan: Failed" + override fun toProperties() = mapOf( + "Time" to time.toString(), + "Exhausted" to exhausted.toString(), + ) + } + } + sealed interface TipCardEvent : AnalyticsEvent { data object Scanned : TipCardEvent { override val name = "Tip Card Scanned" diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt index 7b00ef0a4c..a18a0203ae 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt @@ -251,6 +251,18 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( track(AnalyticsEvent.TipCardEvent.Scanned) } + override fun galleryImagePicked() { + track(AnalyticsEvent.GalleryScanEvent.ImagePicked) + } + + override fun galleryScanSucceeded(tier: Int, zoom: Float, timeMillis: Long) { + track(AnalyticsEvent.GalleryScanEvent.Succeeded(tier, zoom, timeMillis)) + } + + override fun galleryScanFailed(timeMillis: Long, exhausted: Boolean) { + track(AnalyticsEvent.GalleryScanEvent.Failed(timeMillis, exhausted)) + } + override fun tipCardPresented() { track(AnalyticsEvent.TipCardEvent.Presented) } diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 2180e97528..7cd8ec24c9 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -107,6 +107,17 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } + @FeatureFlagMarker + data object ScanFromGallery: FeatureFlag { + override val key: String = "scan_from_gallery" + override val default: Boolean = false + override val launched: Boolean = false + override val visible: Boolean = true + // Matches ContactPickerMode: the setting is about how you reach the camera, not about + // the account you reached it with. + override val persistLogOut: Boolean = true + } + companion object { val entries: List> get() = FeatureFlagEntries.entries @@ -128,6 +139,7 @@ val FeatureFlag<*>.title: String FeatureFlag.ContactPickerMode -> "Contact Picker Mode" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" FeatureFlag.FrostedTipCard -> "Frosted Tip Card" + FeatureFlag.ScanFromGallery -> "Scan from Gallery" } val FeatureFlag<*>.message: String @@ -140,6 +152,7 @@ val FeatureFlag<*>.message: String FeatureFlag.ContactPickerMode -> "When enabled, contacts will be accessed via the system contact picker instead of requesting full READ_CONTACTS permission" FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" FeatureFlag.FrostedTipCard -> "When enabled, the tip card in the scanner renders as frosted glass over a blurred snapshot of the camera instead of a solid card" + FeatureFlag.ScanFromGallery -> "When enabled, the scanner gains a gallery button for searching a photo you already have for a code, for when someone sends you a screenshot instead of showing you their screen" } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index 07039b5800..5d94152b89 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -41,9 +41,37 @@ interface BillOperations { fun claimReceivedFunds(): Boolean } +/** One-shot signals from a scanned code that only the UI can act on. */ +sealed interface CodeScanEvent { + /** + * A decoded cash code had no give request behind it — whoever showed it is no longer showing + * it. + * + * Raised only for a scan that passed `fromStillImage = true`. The camera must never raise it: + * a stale frame is one of sixty a second, and the next one may be live, so a banner there + * would fire on a code the user is still pointing at. A picked photo is read once and cannot + * be re-read, so it is the only path that has to say why nothing happened. + */ + data object CashCodeNotLive : CodeScanEvent +} + interface CodeScanOperations { + /** + * Hot and replay-less, like [TipCardOperations.tipCardEvents]: an event with no scanner on + * screen is dropped. + */ + val codeScanEvents: Flow + fun onCameraScanning(scanning: Boolean) - fun onCodeScan(code: ScannableKikCode) + + /** + * @param fromStillImage true when [code] came from a picked photo rather than the camera. + * Only these scans raise [CodeScanEvent]s. Keeping the distinction here rather than in the + * scanner means the camera cannot be told about a grab it did not start: a photo that resolves + * to something other than a grab — a tip card, say — would otherwise leave the UI waiting, and + * the next stale frame the camera sees would explain itself with someone else's error. + */ + fun onCodeScan(code: ScannableKikCode, fromStillImage: Boolean = false) } interface CashLinkOperations { diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt index ed291d0597..f699b5a780 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt @@ -4,6 +4,7 @@ import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.session.CodeScanEvent import com.flipcash.app.session.CodeScanOperations import com.flipcash.app.session.internal.SessionStateHolder import com.flipcash.app.tokens.TokenCoordinator @@ -11,6 +12,7 @@ import com.flipcash.app.tokens.WalletRevealCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager +import com.getcode.opencode.internal.transactors.GrabTransactorError import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.core.OpenCodePayload import com.getcode.opencode.model.core.PayloadKind @@ -19,8 +21,11 @@ import com.getcode.utils.base58 import com.getcode.utils.hexEncodedString import com.getcode.utils.trace import com.kik.kikx.models.ScannableKikCode +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.consumeAsFlow import javax.inject.Inject import javax.inject.Singleton @@ -67,6 +72,14 @@ class CodeScanDelegate @Inject constructor( private val _events = Channel(Channel.UNLIMITED) val events: Flow = _events.consumeAsFlow() + // Separate from [events], which is the shell's single-consumer channel. This one is the + // scanner UI's, and is replay-less: an event raised with no scanner on screen is dropped. + private val _codeScanEvents = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val codeScanEvents: Flow = _codeScanEvents.asSharedFlow() + private val scannedRendezvous = mutableMapOf() private val scannedTipCards = mutableMapOf() @@ -74,7 +87,7 @@ class CodeScanDelegate @Inject constructor( stateHolder.update { it.copy(isCameraUp = scanning) } } - override fun onCodeScan(code: ScannableKikCode) { + override fun onCodeScan(code: ScannableKikCode, fromStillImage: Boolean) { if (billController.state.value.bill != null) { return } @@ -109,14 +122,14 @@ class CodeScanDelegate @Inject constructor( ) when (codePayload.kind) { - PayloadKind.Cash -> onCashScanned(codePayload) - PayloadKind.MultiMintCash -> onCashScanned(codePayload) + PayloadKind.Cash -> onCashScanned(codePayload, fromStillImage) + PayloadKind.MultiMintCash -> onCashScanned(codePayload, fromStillImage) PayloadKind.Tip -> onTipCardScanned(codePayload) PayloadKind.Unknown -> Unit } } - private fun onCashScanned(payload: OpenCodePayload) { + private fun onCashScanned(payload: OpenCodePayload, fromStillImage: Boolean) { scannedRendezvous[payload.rendezvous.publicKey] = Clock.System.now().toEpochMilliseconds() trace( @@ -161,6 +174,14 @@ class CodeScanDelegate @Inject constructor( error = it ) scannedRendezvous.remove(payload.rendezvous.publicKey) + + // Named rather than folded into the general failure: this is the one grab error + // that is about the code itself rather than about the network or the account, so + // it is the one a caller can explain. Gated on the origin of this scan, not on a + // flag the scanner holds, so a camera frame can never surface a photo's error. + if (fromStillImage && it is GrabTransactorError.GiveRequestNotFound) { + _codeScanEvents.tryEmit(CodeScanEvent.CashCodeNotLive) + } } ) } diff --git a/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/LuminancePlane.kt b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/LuminancePlane.kt index 556a8f5c78..c7bc8421b0 100644 --- a/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/LuminancePlane.kt +++ b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/LuminancePlane.kt @@ -71,4 +71,37 @@ object LuminancePlane { } return cleanData } + + /** + * Converts packed ARGB pixels to the tightly packed 8-bit luminance buffer the scanner + * reads. + * + * A camera frame arrives as a Y plane and needs only [unpad]. A still image is colour, so the + * conversion has to happen somewhere — and it has to happen the same way on both platforms, or + * one fixture screenshot decodes on one and not the other. This is the integer BT.601 form + * `KikCodeScanTest.renderFrame` already uses, mirrored in Swift by + * `CodeExtractor.luminanceSample`. + * + * The weights sum to 256, so the shift produces 0 for black and 255 for white with no clamping + * needed. + */ + fun fromArgb(pixels: IntArray, width: Int, height: Int): ByteArray { + require(pixels.size >= width * height) { + "pixel buffer holds ${pixels.size}, need ${width * height}" + } + + val luminance = ByteArray(width * height) + + for (index in 0 until width * height) { + val pixel = pixels[index] + val luma = ( + 77 * ((pixel shr 16) and 0xFF) + + 150 * ((pixel shr 8) and 0xFF) + + 29 * (pixel and 0xFF) + ) shr 8 + luminance[index] = luma.toByte() + } + + return luminance + } } diff --git a/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/StillImageCodeSearch.kt b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/StillImageCodeSearch.kt new file mode 100644 index 0000000000..acc4e2e816 --- /dev/null +++ b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/StillImageCodeSearch.kt @@ -0,0 +1,155 @@ +package com.getcode.codes.kikcode + +/** + * The order in which a still image is searched for a Kik code. + * + * A camera frame needs none of this: the user aims, so the code is roughly centred and + * roughly the right size. A picked photo is whatever someone captured, and `scanKikCode` + * reads at a fixed scale, so a small code has to be cropped out and scaled up before the + * scanner can see it. + * + * Here rather than in the scanner module because iOS reimplements it by hand — the same + * arrangement as [LuminancePlane], and for the same reason: two copies that must produce + * the same crops in the same order, held together by a test both sides run. + * + * The constants come from Code's `processBitmapRecursively` and `slidingWindowSearch` + * (`f81d064d0`). They have never been measured against a corpus of real screenshots — see + * the spec's open decisions. + */ +object StillImageCodeSearch { + + /** A crop in the image's own pixel coordinates, and how much to scale it before scanning. */ + data class Candidate( + val left: Int, + val top: Int, + val width: Int, + val height: Int, + val zoom: Float, + /** + * Which tier enumerated this crop: 1 whole image, 2 quadrant, 3 sliding window. + * + * Carried so a caller can report how deep a successful search had to go. It is + * deliberately outside the dedup key below -- a window that lands exactly on a quadrant + * is still the same crop, and enumerating it twice under two tier numbers would change + * the search itself to measure it. + */ + val tier: Int, + ) + + /** + * A quadrant is not subdivided below this, because a code smaller than this in the + * original is past the point where scaling recovers it. + */ + const val MIN_SECTION_SIZE = 100 + + const val WINDOW_SIZE = 300 + const val WINDOW_STEP = 150 + + /** + * The longest side any crop is rendered at. This is what makes [Candidate.zoom] mean + * something: a large crop has no room to grow and is scanned once, while a small one + * gets the whole ladder. Without the cap, zoom 10 on a half-frame quadrant would render + * a 9600-pixel bitmap to look for a code that is already plainly visible. + * + * Tier 1 is the exception: the whole image is always tried, whatever its size, so the + * caller clamps it rather than the enumeration dropping it. + */ + const val MAX_RENDERED_SIDE = 1600 + + val ZOOM_LEVELS = listOf(1f, 2f, 5f, 10f) + + /** + * Every crop the search will try, in the order it will try them. + * + * Tier 1 is the whole image, which is the only tier that runs for a screenshot where the + * code fills the frame. Tiers 2 and 3 are the expensive ones and exist for a photo of a + * screen across a room. + */ + fun candidates(imageWidth: Int, imageHeight: Int): List { + val candidates = mutableListOf(Candidate(0, 0, imageWidth, imageHeight, 1f, tier = 1)) + val seen = mutableSetOf(candidates.first().key) + + subdivide(0, 0, imageWidth, imageHeight, candidates, seen) + window(imageWidth, imageHeight, candidates, seen) + + return candidates + } + + private fun subdivide( + left: Int, + top: Int, + width: Int, + height: Int, + candidates: MutableList, + seen: MutableSet, + ) { + val halfWidth = width / 2 + val halfHeight = height / 2 + + if (minOf(halfWidth, halfHeight) < MIN_SECTION_SIZE) return + + for (row in 0 until 2) { + for (column in 0 until 2) { + val quadrantLeft = left + column * halfWidth + val quadrantTop = top + row * halfHeight + + add(quadrantLeft, quadrantTop, halfWidth, halfHeight, 2, candidates, seen) + subdivide(quadrantLeft, quadrantTop, halfWidth, halfHeight, candidates, seen) + } + } + } + + private fun window( + imageWidth: Int, + imageHeight: Int, + candidates: MutableList, + seen: MutableSet, + ) { + if (imageWidth < WINDOW_SIZE || imageHeight < WINDOW_SIZE) return + + var top = 0 + while (top + WINDOW_SIZE <= imageHeight) { + var left = 0 + while (left + WINDOW_SIZE <= imageWidth) { + add(left, top, WINDOW_SIZE, WINDOW_SIZE, 3, candidates, seen) + left += WINDOW_STEP + } + top += WINDOW_STEP + } + } + + /** + * Adds a crop at every zoom that fits under [MAX_RENDERED_SIDE], skipping any crop and + * zoom pair an earlier tier already enumerated. + */ + private fun add( + left: Int, + top: Int, + width: Int, + height: Int, + tier: Int, + candidates: MutableList, + seen: MutableSet, + ) { + val longestSide = maxOf(width, height) + + ZOOM_LEVELS + .filter { longestSide * it <= MAX_RENDERED_SIDE } + .forEach { zoom -> + val candidate = Candidate(left, top, width, height, zoom, tier) + if (seen.add(candidate.key)) candidates += candidate + } + } + + /** A candidate's identity for dedup: the crop and its zoom, without the tier that found it. */ + private data class Key( + val left: Int, + val top: Int, + val width: Int, + val height: Int, + val zoom: Float, + ) + + private val Candidate.key: Key + get() = Key(left, top, width, height, zoom) +} diff --git a/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/LuminancePlaneTest.kt b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/LuminancePlaneTest.kt index 2bd5dfe0a3..bb7965f73b 100644 --- a/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/LuminancePlaneTest.kt +++ b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/LuminancePlaneTest.kt @@ -132,4 +132,32 @@ class LuminancePlaneTest { ) } } + + @Test + fun `fromArgb uses the integer BT601 form`() { + // Pure blue: (77*0 + 150*0 + 29*255) shr 8 == 28. + val pixels = IntArray(4) { 0xFF0000FF.toInt() } + + val luminance = LuminancePlane.fromArgb(pixels, width = 2, height = 2) + + assertTrue(luminance.all { it.toInt() and 0xFF == 28 }, "expected 28, got ${luminance.toList()}") + } + + @Test + fun `fromArgb maps black and white to the ends of the range`() { + val pixels = intArrayOf(0xFF000000.toInt(), 0xFFFFFFFF.toInt()) + + val luminance = LuminancePlane.fromArgb(pixels, width = 2, height = 1) + + assertEquals(0, luminance[0].toInt() and 0xFF) + // 77 + 150 + 29 == 256, so white lands on 255 after the shift, not 256. + assertEquals(255, luminance[1].toInt() and 0xFF) + } + + @Test + fun `fromArgb output is tightly packed`() { + val luminance = LuminancePlane.fromArgb(IntArray(12), width = 4, height = 3) + + assertEquals(12, luminance.size) + } } diff --git a/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/StillImageCodeSearchTest.kt b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/StillImageCodeSearchTest.kt new file mode 100644 index 0000000000..3ca8dd4528 --- /dev/null +++ b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/StillImageCodeSearchTest.kt @@ -0,0 +1,97 @@ +package com.getcode.codes.kikcode + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The ladder is mirrored by hand in `StillImageCodeSearch.swift`. These assertions are the + * contract both copies answer to — the same reason [LuminancePlaneTest] exists. + */ +class StillImageCodeSearchTest { + + @Test + fun `first candidate is the whole image at zoom one`() { + val first = StillImageCodeSearch.candidates(1920, 1080).first() + + assertEquals(0, first.left) + assertEquals(0, first.top) + assertEquals(1920, first.width) + assertEquals(1080, first.height) + assertEquals(1f, first.zoom) + } + + @Test + fun `every candidate lies inside the image`() { + StillImageCodeSearch.candidates(1920, 1080).forEach { candidate -> + assertTrue(candidate.left >= 0 && candidate.top >= 0, "negative origin: $candidate") + assertTrue(candidate.left + candidate.width <= 1920, "overruns width: $candidate") + assertTrue(candidate.top + candidate.height <= 1080, "overruns height: $candidate") + } + } + + @Test + fun `a crop is never rendered larger than the cap`() { + StillImageCodeSearch.candidates(1920, 1080).forEach { candidate -> + // Tier 1 is exempt, the same exemption `quadrants stop subdividing at the + // minimum section size` makes: the whole image is enumerated unconditionally so + // that every image gets a first pass, and the caller clamps it when it renders. + val isWholeImage = candidate.width == 1920 && candidate.height == 1080 + val longest = maxOf(candidate.width, candidate.height) * candidate.zoom + assertTrue( + isWholeImage || longest <= StillImageCodeSearch.MAX_RENDERED_SIDE, + "$candidate renders to $longest" + ) + } + } + + @Test + fun `quadrants stop subdividing at the minimum section size`() { + StillImageCodeSearch.candidates(1920, 1080).forEach { candidate -> + val isWholeImage = candidate.width == 1920 && candidate.height == 1080 + val shortest = minOf(candidate.width, candidate.height) + assertTrue( + isWholeImage || shortest >= StillImageCodeSearch.MIN_SECTION_SIZE, + "section too small: $candidate" + ) + } + } + + @Test + fun `the same crop is never enumerated twice at the same zoom`() { + val candidates = StillImageCodeSearch.candidates(1920, 1080) + + // Compared on the crop and its zoom rather than on the whole candidate: `tier` is part of + // the data class, so `toSet()` alone would let a window that repeats a quadrant through. + val crops = candidates.map { listOf(it.left, it.top, it.width, it.height, it.zoom) } + + assertEquals(crops.size, crops.toSet().size, "duplicate candidates") + } + + @Test + fun `each candidate carries the tier that enumerated it`() { + val candidates = StillImageCodeSearch.candidates(1920, 1080) + + assertEquals(1, candidates.first().tier, "the whole image is tier 1") + assertEquals(1, candidates.count { it.tier == 1 }, "only the whole image is tier 1") + + // Tiers are enumerated in order, so a candidate never follows one from a later tier. + val tiers = candidates.map { it.tier } + assertEquals(tiers.sorted(), tiers, "tiers are out of order") + + assertTrue(candidates.any { it.tier == 2 }, "no quadrant was enumerated") + candidates.filter { it.tier == 3 }.forEach { + assertEquals(StillImageCodeSearch.WINDOW_SIZE, it.width, "$it is not a window") + assertEquals(StillImageCodeSearch.WINDOW_SIZE, it.height, "$it is not a window") + } + } + + @Test + fun `an image smaller than one window still yields the whole image`() { + val candidates = StillImageCodeSearch.candidates(80, 60) + + assertTrue(candidates.isNotEmpty()) + assertEquals(80, candidates.first().width) + assertEquals(60, candidates.first().height) + } +} diff --git a/libs/quickresponse/src/main/kotlin/com/getcode/libs/qr/StaticQrAnalyzer.kt b/libs/quickresponse/src/main/kotlin/com/getcode/libs/qr/StaticQrAnalyzer.kt new file mode 100644 index 0000000000..fa1b3c4f58 --- /dev/null +++ b/libs/quickresponse/src/main/kotlin/com/getcode/libs/qr/StaticQrAnalyzer.kt @@ -0,0 +1,88 @@ +package com.getcode.libs.qr + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.coroutines.resume + +/** + * QR detection over a picked image, the still-image counterpart to [QrCodeAnalyzer]. + * + * No crop ladder here: ML Kit scales internally and finds a small QR in a large photo on its own, + * which is why the still-image search only ever pays the ladder's cost for Kik codes. + * + * Client options match [QrCodeAnalyzer]'s. A still image is a worse case for ML Kit than a frame, + * so there is no reason to widen the format set here. + */ +class StaticQrAnalyzer @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val scanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + ) + + /** + * Every QR payload in the image, in ML Kit's order. + * + * Off the caller's dispatcher: the only caller reaches this from `rememberCoroutineScope`, + * which is the main thread, and decoding an arbitrary photo there would freeze the UI for as + * long as the decode takes. It is also the slow path -- it runs after the Kik ladder has + * already spent its budget -- so this is the worst place to block. + */ + suspend fun detect(uri: Uri): List = withContext(Dispatchers.Default) { + val bitmap = decode(uri) ?: return@withContext emptyList() + scan(bitmap) + } + + /** Takes ownership of [bitmap] and recycles it once ML Kit is finished reading it. */ + private suspend fun scan(bitmap: Bitmap): List = + suspendCancellableCoroutine { continuation -> + scanner.process(InputImage.fromBitmap(bitmap, 0)) + .addOnSuccessListener { barcodes -> + // Recycled here rather than in a `finally` around the await: cancelling the + // search resumes the coroutine immediately while ML Kit's worker is still + // reading the bitmap this image wraps. A task always settles, so the bitmap is + // still freed -- just not before its reader is done. `resume` after + // cancellation is a no-op. + bitmap.recycle() + continuation.resume(barcodes.mapNotNull { it.rawValue }) + } + .addOnFailureListener { + bitmap.recycle() + continuation.resume(emptyList()) + } + } + + private fun decode(uri: Uri): Bitmap? = runCatching { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource(context.contentResolver, uri) + ) { decoder, info, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.isMutableRequired = false + + // The same cap the Kik search uses. ML Kit wants a few hundred pixels across the code, + // not the whole 12-megapixel frame, and an ARGB_8888 decode of one is 48MB. + val longestSide = maxOf(info.size.width, info.size.height) + var sample = 1 + while (longestSide / sample > MAX_SOURCE_SIDE) sample *= 2 + decoder.setTargetSampleSize(sample) + } + }.getOrNull() + + private companion object { + const val MAX_SOURCE_SIDE = 2400 + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/utils/KeepScreenOn.kt b/ui/components/src/main/kotlin/com/getcode/ui/utils/KeepScreenOn.kt index 9550b8bffe..a3d5ee6343 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/utils/KeepScreenOn.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/utils/KeepScreenOn.kt @@ -53,7 +53,9 @@ fun KeepScreenOn( var originalBrightness by remember { mutableFloatStateOf(WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE) } var brightnessAdjusted by remember { mutableStateOf(false) } - DisposableEffect(isEnabled, lifecycleOwner) { + // `useBrightness` is a key because the effect reads it when it applies the overrides. Without + // it, a caller that flips brightness on while `isEnabled` stays true keeps the old behaviour. + DisposableEffect(isEnabled, useBrightness, lifecycleOwner) { if (!isEnabled) return@DisposableEffect onDispose { } val window = (context as Activity).window diff --git a/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/processing/MultiCodeAnalyzer.kt b/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/processing/MultiCodeAnalyzer.kt index d040f17e7d..e40119dab1 100644 --- a/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/processing/MultiCodeAnalyzer.kt +++ b/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/processing/MultiCodeAnalyzer.kt @@ -7,9 +7,11 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext import com.getcode.libs.code.detection.CodeDetector import com.getcode.libs.code.detection.CodeScanResult import com.getcode.libs.qr.QrCodeAnalyzer +import com.getcode.media.StaticImageAnalyzerImpl import com.kik.kikx.kikcodes.implementation.KikCodeAnalyzer import com.kik.kikx.kikcodes.implementation.KikCodeScannerImpl import kotlinx.coroutines.CoroutineExceptionHandler @@ -29,9 +31,20 @@ fun rememberMultiCodeAnalyzer( val currentOnCodeScanned by rememberUpdatedState(onCodeScanned) val currentOnError by rememberUpdatedState(onError) + val context = LocalContext.current + val qrCodeAnalyzer = remember { QrCodeAnalyzer() } val kikCodeScanner = remember { KikCodeScannerImpl() } - val kikCodeAnalyzer = remember(kikCodeScanner) { KikCodeAnalyzer(kikCodeScanner) } + // `staticImageAnalyzer` used to be an `@Inject lateinit var` on `KikCodeAnalyzer`, which field + // injection never filled in because this composable builds the analyzer itself -- the first + // `detect(uri)` would have thrown. It is a constructor argument now, built the same way as + // everything else here. + val staticImageAnalyzer = remember(kikCodeScanner) { + StaticImageAnalyzerImpl(context.applicationContext, kikCodeScanner) + } + val kikCodeAnalyzer = remember(kikCodeScanner, staticImageAnalyzer) { + KikCodeAnalyzer(kikCodeScanner, staticImageAnalyzer) + } val detectors = remember(qrCodeAnalyzer, kikCodeAnalyzer) { listOf(kikCodeAnalyzer, qrCodeAnalyzer) diff --git a/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/StillImageScanTest.kt b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/StillImageScanTest.kt new file mode 100644 index 0000000000..a1c079d7a6 --- /dev/null +++ b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/StillImageScanTest.kt @@ -0,0 +1,218 @@ +package com.kik.scan + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import androidx.core.content.ContextCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.getcode.codes.kikcode.LuminancePlane +import com.getcode.codes.kikcode.StillImageCodeSearch +import com.getcode.media.StaticImageAnalyzerImpl +import com.getcode.media.StaticImageResult +import com.kik.kikx.kikcodes.implementation.KikCodeScannerImpl +import com.kik.kikx.kincodes.KikCodeContentRendererImpl +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The still-image half of the scanner harness. [KikCodeScanTest] covers the camera path; this + * covers what a picked photo goes through, which shares the decoder and nothing else. + */ +@RunWith(AndroidJUnit4::class) +class StillImageScanTest { + + // Constructed exactly as `KikCodeScanTest` constructs it -- copy that line rather than this + // one if the two disagree. + private val scanner = KikCodeScannerImpl() + + @Test + fun codeFillingTheFrameDecodesOnTheFirstTier() = runBlocking { + val bitmap = renderScene(width = 1200, height = 1200, codeSide = 1000) + + val result = search(bitmap) + + assertTrue("expected a code, got $result", result is StaticImageResult.Found) + assertEquals(1, (result as StaticImageResult.Found).tier) + } + + @Test + fun smallOffCentreCodeDecodesFromALaterTier() = runBlocking { + // Sized so the first tier genuinely cannot do it. Tier 1 renders the whole frame clamped + // to MAX_RENDERED_SIDE, so 3200 wide is halved and the code arrives at 130px; the top-left + // quadrant is 1600 wide, renders 1:1, and hands the scanner the same 260px the + // frame-filling case proves is readable. Until this scene was widened the test decoded on + // tier 1 and its name was aspirational -- nothing exercised the quadrants end to end. + val bitmap = renderScene( + width = 3200, + height = 2400, + codeSide = 260, + left = 120, + top = 700, + ) + + val result = search(bitmap) + + assertTrue("expected a code, got $result", result is StaticImageResult.Found) + // The whole 1600x1200 frame is one 260px code on black, which the first tier cannot + // resolve -- this is the case the ladder exists for, so name the tier it took. + assertTrue( + "expected a later tier, got $result", + (result as StaticImageResult.Found).tier > 1, + ) + } + + @Test + fun anImageWithNoCodeFindsNothing() = runBlocking { + val bitmap = Bitmap.createBitmap(1200, 900, Bitmap.Config.ARGB_8888).also { + Canvas(it).drawColor(Color.DKGRAY) + } + + assertEquals(StaticImageResult.NotFound, search(bitmap)) + } + + @Test + fun aFruitlessSearchStaysWithinTheBudget() = runBlocking { + // Large enough that the full ladder cannot finish in the budget, so what ends the search + // is the deadline rather than the candidates running out. + val bitmap = Bitmap.createBitmap(3000, 2000, Bitmap.Config.ARGB_8888).also { + Canvas(it).drawColor(Color.DKGRAY) + } + + val started = System.currentTimeMillis() + val result = searchWithBudget(bitmap, budgetMillis = 500) + val elapsed = System.currentTimeMillis() - started + + assertEquals(StaticImageResult.Exhausted, result) + // One crop may overrun, since the budget is checked between candidates rather than inside + // the native scanner. The margin is for that, not for slack. + assertTrue("budget of 500ms took ${elapsed}ms", elapsed < 3000) + } + + // MARK: helpers + + /** + * Walks the ladder exactly as [com.getcode.media.StaticImageAnalyzerImpl] does, against a + * bitmap rather than a Uri, so the test needs no content resolver. + */ + private suspend fun search(bitmap: Bitmap): StaticImageResult = + searchWithBudget(bitmap, budgetMillis = Long.MAX_VALUE) + + private suspend fun searchWithBudget(bitmap: Bitmap, budgetMillis: Long): StaticImageResult { + val started = System.currentTimeMillis() + + StillImageCodeSearch.candidates(bitmap.width, bitmap.height).forEach { candidate -> + if (System.currentTimeMillis() - started > budgetMillis) { + return StaticImageResult.Exhausted + } + + val longestSide = maxOf(candidate.width, candidate.height) + val renderedSide = minOf( + longestSide * candidate.zoom, + StillImageCodeSearch.MAX_RENDERED_SIDE.toFloat(), + ) + val scale = renderedSide / longestSide + val width = maxOf((candidate.width * scale).toInt(), 1) + val height = maxOf((candidate.height * scale).toInt(), 1) + + val crop = Bitmap.createBitmap( + bitmap, + candidate.left, + candidate.top, + candidate.width, + candidate.height, + ) + val scaled = Bitmap.createScaledBitmap(crop, width, height, true) + + val pixels = IntArray(width * height) + scaled.getPixels(pixels, 0, width, 0, 0, width, height) + + if (crop != bitmap) crop.recycle() + if (scaled != crop && scaled != bitmap) scaled.recycle() + + val luminance = LuminancePlane.fromArgb(pixels, width, height) + val scanned = scanner.scanKikCode(luminance, width, height).getOrNull() + + if (scanned != null) { + return StaticImageResult.Found(scanned, candidate.tier, candidate.zoom) + } + } + + return StaticImageResult.NotFound + } + + /** + * A code drawn on black. Polarity matches [KikCodeScanTest]: the detector thresholds for + * bright blobs and fits an ellipse to the centre badge, so black-on-white leaves the badge as + * a dark hole and nothing is ever found. The badge well must be filled with the real logo + * artwork for the same reason -- see [KikCodeScanTest]'s note on it. + */ + private fun renderScene( + width: Int, + height: Int, + codeSide: Int, + left: Int = (width - codeSide) / 2, + top: Int = (height - codeSide) / 2, + ): Bitmap { + val renderer = KikCodeContentRendererImpl().apply { + badge = requireNotNull( + ContextCompat.getDrawable( + InstrumentationRegistry.getInstrumentation().context, + com.kik.kikx.test.R.drawable.ic_logo_round_white, + ) + ) + } + val encoded = requireNotNull(Scanner.encode(PAYLOAD)) { "native encode returned null" } + + val code = Bitmap.createBitmap(codeSide, codeSide, Bitmap.Config.ARGB_8888) + Canvas(code).also { canvas -> + canvas.drawColor(Color.BLACK) + renderer.render(encoded, codeSide, canvas) + } + + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { scene -> + Canvas(scene).also { canvas -> + canvas.drawColor(Color.BLACK) + canvas.drawBitmap(code, left.toFloat(), top.toFloat(), Paint()) + } + code.recycle() + } + } + + private companion object { + /** A remote code payload is 20 bytes, the same shape `KikCodeScanTest` encodes. */ + val PAYLOAD = ByteArray(20) { ((it * 7 + 11) % 251).toByte() } + } + + @Test + fun sourceDownsamplingBringsEveryPhotoUnderTheCap() { + val cap = StaticImageAnalyzerImpl.MAX_SOURCE_SIDE + + // The case that regressed: `longestSide / cap` truncates to 1 for anything under twice the + // cap, so a 4000px photo -- a perfectly ordinary one -- was decoded at full size and the + // ladder then paid a quadratic cost for it. + assertEquals(2, StaticImageAnalyzerImpl.sampleSizeFor(4000)) + + listOf(2401, 3000, 4000, 4800, 7200, 12000).forEach { side -> + val sample = StaticImageAnalyzerImpl.sampleSizeFor(side) + assertTrue("$side / $sample is still over $cap", side / sample <= cap) + assertTrue("$sample is not a power of two", sample and (sample - 1) == 0) + assertTrue( + "$side was sampled harder than it needed", + sample == 1 || side / (sample / 2) > cap, + ) + } + } + + @Test + fun anImageUnderTheCapIsNotDownsampled() { + listOf(1, 300, 1600, 2400).forEach { side -> + assertEquals(1, StaticImageAnalyzerImpl.sampleSizeFor(side)) + } + } + +} diff --git a/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzer.kt b/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzer.kt index 0a94c5ad2c..e39d92547d 100644 --- a/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzer.kt +++ b/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzer.kt @@ -3,6 +3,32 @@ package com.getcode.media import android.net.Uri import com.kik.kikx.models.ScannableKikCode +/** What a still-image search ended up finding. */ +sealed interface StaticImageResult { + /** + * [tier] and [zoom] are the crop that decoded, reported so the caller can say how deep the + * ladder had to go. The ladder's constants are inherited and unmeasured, and this is the + * only place the depth a real image needed is visible. + */ + data class Found( + val code: ScannableKikCode, + val tier: Int, + val zoom: Float, + ) : StaticImageResult + + /** The ladder was walked to the end and nothing decoded. */ + data object NotFound : StaticImageResult + + /** The budget expired before the ladder ended. Not the same as [NotFound]. */ + data object Exhausted : StaticImageResult +} + +/** + * Searches a picked or shared image for a Kik code. + * + * Implemented by [StaticImageAnalyzerImpl]; kept as an interface so `androidTest` can drive the + * search with synthesized bitmaps and no content resolver. + */ interface StaticImageAnalyzer { - suspend fun analyze(uri: Uri): Result -} \ No newline at end of file + suspend fun analyze(uri: Uri): StaticImageResult +} diff --git a/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzerImpl.kt b/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzerImpl.kt new file mode 100644 index 0000000000..6d28366ea6 --- /dev/null +++ b/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzerImpl.kt @@ -0,0 +1,126 @@ +package com.getcode.media + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import com.getcode.codes.kikcode.LuminancePlane +import com.getcode.codes.kikcode.StillImageCodeSearch +import com.kik.kikx.kikcodes.KikCodeScanner +import com.kik.kikx.models.ScannableKikCode +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +class StaticImageAnalyzerImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val scanner: KikCodeScanner, +) : StaticImageAnalyzer { + + override suspend fun analyze(uri: Uri): StaticImageResult = withContext(Dispatchers.Default) { + val bitmap = decode(uri) ?: return@withContext StaticImageResult.NotFound + + try { + search(bitmap, BUDGET) + } finally { + bitmap.recycle() + } + } + + private suspend fun search(bitmap: Bitmap, budget: Duration): StaticImageResult { + val started = TimeSource.Monotonic.markNow() + + StillImageCodeSearch.candidates(bitmap.width, bitmap.height).forEach { candidate -> + currentCoroutineContext().ensureActive() + + if (started.elapsedNow() > budget) return StaticImageResult.Exhausted + + val scanned = scan(bitmap, candidate) + if (scanned != null) { + return StaticImageResult.Found(scanned, candidate.tier, candidate.zoom) + } + } + + return StaticImageResult.NotFound + } + + private suspend fun scan( + bitmap: Bitmap, + candidate: StillImageCodeSearch.Candidate, + ): ScannableKikCode? { + val longestSide = maxOf(candidate.width, candidate.height) + val renderedSide = minOf( + longestSide * candidate.zoom, + StillImageCodeSearch.MAX_RENDERED_SIDE.toFloat(), + ) + val scale = renderedSide / longestSide + val width = maxOf((candidate.width * scale).toInt(), 1) + val height = maxOf((candidate.height * scale).toInt(), 1) + + val crop = Bitmap.createBitmap( + bitmap, + candidate.left, + candidate.top, + candidate.width, + candidate.height, + ) + val scaled = Bitmap.createScaledBitmap(crop, width, height, true) + + val pixels = IntArray(width * height) + scaled.getPixels(pixels, 0, width, 0, 0, width, height) + + // `createBitmap` and `createScaledBitmap` both return the source unchanged when nothing + // needs doing, so recycling unconditionally would free the caller's bitmap. + if (crop != bitmap) crop.recycle() + if (scaled != crop && scaled != bitmap) scaled.recycle() + + val luminance = LuminancePlane.fromArgb(pixels, width, height) + + return scanner.scanKikCode(luminance, width, height).getOrNull() + } + + private fun decode(uri: Uri): Bitmap? = runCatching { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource(context.contentResolver, uri) + ) { decoder, info, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.isMutableRequired = false + + // A modern phone photo is 4000px across, and the ladder's cost is quadratic in that. + // Downsampling first costs nothing the scanner can see -- a code too small to survive + // this is already too small for the deepest zoom to recover. + val longestSide = maxOf(info.size.width, info.size.height) + decoder.setTargetSampleSize(sampleSizeFor(longestSide)) + } + }.getOrNull() + + internal companion object { + /** + * The smallest power of two that brings [longestSide] under [MAX_SOURCE_SIDE]. + * + * Rounding up rather than down is the whole point: `longestSide / MAX_SOURCE_SIDE` + * truncates, so a 4000px photo -- the case the caller's comment names -- divides to 1 and + * is left at full size. Every image between the cap and twice the cap was untouched. + */ + internal fun sampleSizeFor(longestSide: Int): Int { + var sample = 1 + while (longestSide / sample > MAX_SOURCE_SIDE) sample *= 2 + return sample + } + + /** + * How long the whole search may take. Long enough that no successful search has ever hit + * it, short enough that a photo of a wall gives up. A guess, not a measurement -- see the + * spec's open decisions. + */ + val BUDGET: Duration = 8.seconds + + internal const val MAX_SOURCE_SIDE = 2400 + } +} diff --git a/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kikcodes/implementation/KikCodeAnalyzer.kt b/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kikcodes/implementation/KikCodeAnalyzer.kt index 9ee9d35fa4..99256238a5 100644 --- a/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kikcodes/implementation/KikCodeAnalyzer.kt +++ b/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kikcodes/implementation/KikCodeAnalyzer.kt @@ -5,6 +5,7 @@ import androidx.camera.core.ImageProxy import com.getcode.libs.code.detection.CodeDetector import com.getcode.libs.code.detection.CodeScanResult import com.getcode.media.StaticImageAnalyzer +import com.getcode.media.StaticImageResult import com.getcode.util.toByteArray import com.kik.kikx.kikcodes.KikCodeScanner import com.kik.kikx.kikcodes.ScannerError @@ -16,11 +17,9 @@ data class KikCodeResult(val kikCode: ScannableKikCode) : CodeScanResult class KikCodeAnalyzer @Inject constructor( private val scanner: KikCodeScanner, + private val staticImageAnalyzer: StaticImageAnalyzer, ) : CodeDetector { - @Inject - lateinit var staticImageAnalyzer: StaticImageAnalyzer - override suspend fun detect( image: ImageProxy, ): CodeScanResult? { @@ -32,12 +31,9 @@ class KikCodeAnalyzer @Inject constructor( } } - suspend fun detect(uri: Uri): CodeScanResult? { - return staticImageAnalyzer.analyze(uri) - .map { KikCodeResult(it) } - .getOrElse { error -> - if (error !is ScannerError) throw error - null - } - } + /** + * The still-image path returns the analyzer's result unchanged: the caller has to tell + * `Exhausted` from `NotFound` to say anything useful about a search that found nothing. + */ + suspend fun detect(uri: Uri): StaticImageResult = staticImageAnalyzer.analyze(uri) } \ No newline at end of file