From b9779cca564208b009921503adfc6623da7a374c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 22:12:50 -0400 Subject: [PATCH 01/19] feat(kikcode): enumerate the still-image crop ladder --- .../codes/kikcode/StillImageCodeSearch.kt | 133 ++++++++++++++++++ .../codes/kikcode/StillImageCodeSearchTest.kt | 75 ++++++++++ 2 files changed, 208 insertions(+) create mode 100644 libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/StillImageCodeSearch.kt create mode 100644 libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/StillImageCodeSearchTest.kt 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..437d8256a2 --- /dev/null +++ b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/StillImageCodeSearch.kt @@ -0,0 +1,133 @@ +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, + ) + + /** + * 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)) + val seen = mutableSetOf(candidates.first()) + + 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, 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, 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, + 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) + if (seen.add(candidate)) candidates += candidate + } + } +} 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..3c872b9baf --- /dev/null +++ b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/StillImageCodeSearchTest.kt @@ -0,0 +1,75 @@ +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) + + assertEquals(candidates.size, candidates.toSet().size, "duplicate candidates") + } + + @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) + } +} From 56e3eb42c0dd30f2818d9d5fcf3febd96b9170ca Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 22:12:50 -0400 Subject: [PATCH 02/19] feat(kikcode): convert ARGB pixels to a luminance plane --- .../getcode/codes/kikcode/LuminancePlane.kt | 33 +++++++++++++++++++ .../codes/kikcode/LuminancePlaneTest.kt | 28 ++++++++++++++++ 2 files changed, 61 insertions(+) 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/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) + } } From e9d721d40ac76d7777231fb9e055a517f9fc4ffc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 22:28:02 -0400 Subject: [PATCH 03/19] feat(scanner): search a still image for a Kik code --- .../kotlin/com/kik/scan/StillImageScanTest.kt | 175 ++++++++++++++++++ .../com/getcode/media/StaticImageAnalyzer.kt | 21 ++- .../getcode/media/StaticImageAnalyzerImpl.kt | 114 ++++++++++++ .../implementation/KikCodeAnalyzer.kt | 14 +- 4 files changed, 314 insertions(+), 10 deletions(-) create mode 100644 vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/StillImageScanTest.kt create mode 100644 vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzerImpl.kt 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..e55e9fec1e --- /dev/null +++ b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/StillImageScanTest.kt @@ -0,0 +1,175 @@ +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.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) + } + + @Test + fun smallOffCentreCodeDecodesFromALaterTier() = runBlocking { + val bitmap = renderScene( + width = 1600, + height = 1200, + codeSide = 260, + left = 120, + top = 700, + ) + + val result = search(bitmap) + + assertTrue("expected a code, got $result", result is StaticImageResult.Found) + } + + @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) + } + + 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() } + } +} 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..49e6eab8cb 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,23 @@ 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 { + data class Found(val code: ScannableKikCode) : StaticImageResult + + /** The ladder was walked to the end and nothing decoded. */ + data object NotFound : StaticImageResult + + /** The budget expired or the caller cancelled. 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..4a86b09460 --- /dev/null +++ b/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzerImpl.kt @@ -0,0 +1,114 @@ +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) + } + + 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) + if (longestSide > MAX_SOURCE_SIDE) { + val sample = longestSide / MAX_SOURCE_SIDE + decoder.setTargetSampleSize(Integer.highestOneBit(sample)) + } + } + }.getOrNull() + + private companion object { + /** + * 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 + + 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..4e64ec4733 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 @@ -32,12 +33,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 From 2a99550dc6b286f4755cf800446ea4972287cf54 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 22:31:44 -0400 Subject: [PATCH 04/19] feat(scanner): detect a QR code in a picked image --- .../features/scanner/build.gradle.kts | 1 + .../com/getcode/libs/qr/StaticQrAnalyzer.kt | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 libs/quickresponse/src/main/kotlin/com/getcode/libs/qr/StaticQrAnalyzer.kt diff --git a/apps/flipcash/features/scanner/build.gradle.kts b/apps/flipcash/features/scanner/build.gradle.kts index b027282036..a36927e66f 100644 --- a/apps/flipcash/features/scanner/build.gradle.kts +++ b/apps/flipcash/features/scanner/build.gradle.kts @@ -20,6 +20,7 @@ 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")) 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..bf2403bdf1 --- /dev/null +++ b/libs/quickresponse/src/main/kotlin/com/getcode/libs/qr/StaticQrAnalyzer.kt @@ -0,0 +1,63 @@ +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.suspendCancellableCoroutine +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. */ + suspend fun detect(uri: Uri): List { + val bitmap = runCatching { + ImageDecoder.decodeBitmap( + ImageDecoder.createSource(context.contentResolver, uri) + ) { decoder, _, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.isMutableRequired = false + } + }.getOrNull() ?: return emptyList() + + return try { + detect(bitmap) + } finally { + bitmap.recycle() + } + } + + private suspend fun detect(bitmap: Bitmap): List = + suspendCancellableCoroutine { continuation -> + scanner.process(InputImage.fromBitmap(bitmap, 0)) + .addOnSuccessListener { barcodes -> + continuation.resume(barcodes.mapNotNull { it.rawValue }) + } + .addOnFailureListener { + continuation.resume(emptyList()) + } + } +} From d2b47c55ce20296466ac89b52185e142cfef9b84 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 22:34:44 -0400 Subject: [PATCH 05/19] fix(scanner): inject the static image analyzer through the constructor --- .../ui/scanner/processing/MultiCodeAnalyzer.kt | 15 ++++++++++++++- .../kikcodes/implementation/KikCodeAnalyzer.kt | 4 +--- 2 files changed, 15 insertions(+), 4 deletions(-) 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/main/kotlin/com/kik/kikx/kikcodes/implementation/KikCodeAnalyzer.kt b/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kikcodes/implementation/KikCodeAnalyzer.kt index 4e64ec4733..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 @@ -17,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? { From fa0c779c200df16cb3b68f17190e84f1a6f37235 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 22:36:40 -0400 Subject: [PATCH 06/19] refactor(scanner): name the scannable route allowlist --- .../app/core/navigation/DeeplinkType.kt | 29 ++++++++++ .../core/navigation/DeeplinkScanPolicyTest.kt | 55 +++++++++++++++++++ .../flipcash/app/scanner/internal/Scanner.kt | 5 +- 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt 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/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/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..a1e8c15fed 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 @@ -99,7 +99,10 @@ internal fun Scanner() { analytics.deeplinkParsed(type, url) type } - if (deeplink != null) { + // Stated once and by name. The branches below still refuse + // credential routes on their own, but that refusal used to be an + // emergent property of two `when`s in different places. + if (deeplink != null && deeplink.isScannable) { vibrator.vibrate(duration = 50) when (deeplink) { is DeeplinkType.CashLink -> { From 267b070f438cfa964552e1a758da684763839533 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 22:37:18 -0400 Subject: [PATCH 07/19] feat(scanner): add the scan-from-gallery feature flag --- .../com/flipcash/app/featureflags/FeatureFlag.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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" } From bbd250ff8e4a497e2c734cd8bc07f140c2cd108c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 22:41:41 -0400 Subject: [PATCH 08/19] feat(scanner): add the gallery glyph --- .../app/scanner/internal/GalleryScanButton.kt | 59 +++++++++++++++++++ .../scanner/src/main/res/values/strings.xml | 6 ++ 2 files changed, 65 insertions(+) create mode 100644 apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/GalleryScanButton.kt create mode 100644 apps/flipcash/features/scanner/src/main/res/values/strings.xml 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..74184af670 --- /dev/null +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/GalleryScanButton.kt @@ -0,0 +1,59 @@ +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.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +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.material.icons.Icons +import androidx.compose.material.icons.filled.PhotoLibrary +import com.flipcash.features.scanner.R +import com.getcode.ui.core.unboundedClickable + +/** + * The Scan tab's gallery entry point. + * + * `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. + */ +@Composable +internal fun GalleryScanButton( + onImagePicked: (Uri) -> Unit, + modifier: Modifier = Modifier, +) { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> uri?.let(onImagePicked) } + + Box( + modifier = modifier + // A 44dp target around a 32dp glyph: the glyph matches the tab bar's icon size so the + // control reads as the same weight as a tab, and the target is the minimum that is + // comfortable to hit at the bottom corner. The ripple is unbounded so it is not + // clipped to that box -- the glyph sits on the camera preview with no surface behind + // it, and a square ripple there would read as a button that is not drawn. + .size(44.dp) + .unboundedClickable { + launcher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.PhotoLibrary, + contentDescription = stringResource(R.string.content_description_scanFromGallery), + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } +} diff --git a/apps/flipcash/features/scanner/src/main/res/values/strings.xml b/apps/flipcash/features/scanner/src/main/res/values/strings.xml new file mode 100644 index 0000000000..bec4a09bea --- /dev/null +++ b/apps/flipcash/features/scanner/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + + Scan a code from a photo + + From d4ed759cfa02369382847bfcef6ebad48aea8173 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 22:51:33 -0400 Subject: [PATCH 09/19] feat(scanner): scan a photo picked from the gallery --- .../flipcash/app/scanner/internal/Scanner.kt | 284 +++++++++++++----- .../internal/bills/ScannableContainer.kt | 34 ++- .../scanner/src/main/res/values/strings.xml | 6 + .../flipcash/app/session/SessionController.kt | 20 ++ .../internal/delegates/CodeScanDelegate.kt | 20 ++ 5 files changed, 292 insertions(+), 72 deletions(-) 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 a1e8c15fed..7b32916b54 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,34 @@ 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 @Composable internal fun Scanner() { @@ -49,10 +76,112 @@ 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) } + var scanJob by remember { mutableStateOf(null) } + + // A grab started from a picked photo, still waiting on the server. The camera raises + // `CashCodeNotLive` too and is meant to ignore it, so the flag is what separates the two. + var awaitingStillImageGrab by remember { mutableStateOf(false) } + + 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_noCodeInPhoto), + message = resources.getString(R.string.error_description_noCodeInPhoto), + ) + } + + val onImagePicked = { uri: Uri -> + scanJob?.cancel() + scanJob = scope.launch { + isScanningStillImage = true + try { + when (val result = kikCodeAnalyzer.detect(uri)) { + is StaticImageResult.Found -> { + // 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. + awaitingStillImageGrab = true + session.onCodeScan(result.code) + } + // 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 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 +196,22 @@ internal fun Scanner() { } } + LaunchedEffect(session) { + session.codeScanEvents.collect { event -> + when (event) { + CodeScanEvent.CashCodeNotLive -> { + if (awaitingStillImageGrab) { + awaitingStillImageGrab = false + TopBarManager.showMessage( + title = resources.getString(R.string.error_title_cashCodeNotLive), + message = resources.getString(R.string.error_description_cashCodeNotLive), + ) + } + } + } + } + } + LaunchedEffect(biometricsState, previewing) { if (previewing == true) { focusManager.clearFocus() @@ -79,80 +224,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) } - // Stated once and by name. The branches below still refuse - // credential routes on their own, but that refusal used to be an - // emergent property of two `when`s in different places. - if (deeplink != null && deeplink.isScannable) { - 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?.cancel() }) + } + } DisposableEffect(LocalCodeNavigator.current) { onDispose { @@ -166,12 +274,46 @@ internal fun Scanner() { LaunchedEffect(billState.bill) { if (billState.bill != null) { + // The grab landed, so there is nothing left to explain. + awaitingStillImageGrab = false navigator.hide() } } + // 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..6896f83be6 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,20 +1,27 @@ 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.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.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.android.extensions.launchAppSettings +import com.flipcash.app.core.navigation.LocalTabBarPadding +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 @@ -35,6 +42,7 @@ import com.getcode.util.permissions.rememberCameraPermission */ @Composable internal fun ScannableContainer( + onImagePicked: (Uri) -> Unit, modifier: Modifier = Modifier, scannerView: @Composable () -> Unit, ) { @@ -65,6 +73,7 @@ internal fun ScannableContainer( } val state by session.state.collectAsStateWithLifecycle() + val billState by session.billState.collectAsStateWithLifecycle() Box( modifier = Modifier @@ -105,5 +114,28 @@ internal fun ScannableContainer( } } } + + val galleryEnabled by LocalFeatureFlags.current + .observe(FeatureFlag.ScanFromGallery) + .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 only while a bill is up, + // because a bill is drawn at the app root over this container and the glyph would sit on + // top of it. + if (galleryEnabled && billState.bill == null) { + GalleryScanButton( + onImagePicked = onImagePicked, + modifier = Modifier + .align(Alignment.BottomStart) + // `LocalTabBarPadding` is the space the hoisted tab bar occupies, provided per + // nav entry by `NavTabBarInsetEntryDecorator`. Reading it rather than hard-coding + // a height is what keeps the glyph clear of the pill when the bar resizes. + .padding( + start = 20.dp, + bottom = LocalTabBarPadding.current.calculateBottomPadding() + 16.dp, + ) + ) + } } } diff --git a/apps/flipcash/features/scanner/src/main/res/values/strings.xml b/apps/flipcash/features/scanner/src/main/res/values/strings.xml index bec4a09bea..ae3cf3aec1 100644 --- a/apps/flipcash/features/scanner/src/main/res/values/strings.xml +++ b/apps/flipcash/features/scanner/src/main/res/values/strings.xml @@ -3,4 +3,10 @@ Scan a code from a photo + No Code Found + We couldn\'t find a code in that photo. + + Cash Code Expired + That cash code isn\'t live any more. Ask them to show it on their screen while you scan. + 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..400246fff2 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,7 +41,27 @@ 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. + * + * The camera path ignores this, and should: 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 the gallery path is the only caller + * 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) } 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..72fa72fa9e 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() @@ -161,6 +174,13 @@ 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. Only the still-image path listens. + if (it is GrabTransactorError.GiveRequestNotFound) { + _codeScanEvents.tryEmit(CodeScanEvent.CashCodeNotLive) + } } ) } From edc03814633ed547236b0f0f6b6dea3ee887cf06 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 22:55:38 -0400 Subject: [PATCH 10/19] feat(scanner): report gallery scan outcomes --- .../flipcash/app/scanner/internal/Scanner.kt | 20 +++++++++- .../com/flipcash/app/analytics/Analytics.kt | 12 ++++++ .../com/flipcash/app/analytics/Events.kt | 38 ++++++++++++++++++ .../internal/MixpanelAnalyticsDelegate.kt | 12 ++++++ .../codes/kikcode/StillImageCodeSearch.kt | 40 ++++++++++++++----- .../codes/kikcode/StillImageCodeSearchTest.kt | 24 ++++++++++- .../com/getcode/media/StaticImageAnalyzer.kt | 11 ++++- .../getcode/media/StaticImageAnalyzerImpl.kt | 4 +- 8 files changed, 148 insertions(+), 13 deletions(-) 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 7b32916b54..34f34ae21d 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 @@ -58,6 +58,7 @@ import com.kik.kikx.kikcodes.implementation.KikCodeScannerImpl import dev.theolm.rinku.DeepLink import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlin.time.TimeSource @Composable internal fun Scanner() { @@ -155,11 +156,18 @@ internal fun Scanner() { val onImagePicked = { uri: Uri -> scanJob?.cancel() + analytics.galleryImagePicked() scanJob = 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 @@ -173,7 +181,17 @@ internal fun Scanner() { StaticImageResult.Exhausted, -> { val deeplink = firstScannableDeeplink(staticQrAnalyzer.detect(uri)) - if (deeplink != null) onDeeplinkScanned(deeplink) else showNoCodeFound() + 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 { 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/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 index 437d8256a2..acc4e2e816 100644 --- 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 @@ -25,6 +25,15 @@ object StillImageCodeSearch { 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, ) /** @@ -57,8 +66,8 @@ object StillImageCodeSearch { * screen across a room. */ fun candidates(imageWidth: Int, imageHeight: Int): List { - val candidates = mutableListOf(Candidate(0, 0, imageWidth, imageHeight, 1f)) - val seen = mutableSetOf(candidates.first()) + 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) @@ -72,7 +81,7 @@ object StillImageCodeSearch { width: Int, height: Int, candidates: MutableList, - seen: MutableSet, + seen: MutableSet, ) { val halfWidth = width / 2 val halfHeight = height / 2 @@ -84,7 +93,7 @@ object StillImageCodeSearch { val quadrantLeft = left + column * halfWidth val quadrantTop = top + row * halfHeight - add(quadrantLeft, quadrantTop, halfWidth, halfHeight, candidates, seen) + add(quadrantLeft, quadrantTop, halfWidth, halfHeight, 2, candidates, seen) subdivide(quadrantLeft, quadrantTop, halfWidth, halfHeight, candidates, seen) } } @@ -94,7 +103,7 @@ object StillImageCodeSearch { imageWidth: Int, imageHeight: Int, candidates: MutableList, - seen: MutableSet, + seen: MutableSet, ) { if (imageWidth < WINDOW_SIZE || imageHeight < WINDOW_SIZE) return @@ -102,7 +111,7 @@ object StillImageCodeSearch { while (top + WINDOW_SIZE <= imageHeight) { var left = 0 while (left + WINDOW_SIZE <= imageWidth) { - add(left, top, WINDOW_SIZE, WINDOW_SIZE, candidates, seen) + add(left, top, WINDOW_SIZE, WINDOW_SIZE, 3, candidates, seen) left += WINDOW_STEP } top += WINDOW_STEP @@ -118,16 +127,29 @@ object StillImageCodeSearch { top: Int, width: Int, height: Int, + tier: Int, candidates: MutableList, - seen: MutableSet, + 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) - if (seen.add(candidate)) candidates += candidate + 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/StillImageCodeSearchTest.kt b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/StillImageCodeSearchTest.kt index 3c872b9baf..3ca8dd4528 100644 --- 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 @@ -61,7 +61,29 @@ class StillImageCodeSearchTest { fun `the same crop is never enumerated twice at the same zoom`() { val candidates = StillImageCodeSearch.candidates(1920, 1080) - assertEquals(candidates.size, candidates.toSet().size, "duplicate candidates") + // 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 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 49e6eab8cb..8a71834933 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 @@ -5,7 +5,16 @@ import com.kik.kikx.models.ScannableKikCode /** What a still-image search ended up finding. */ sealed interface StaticImageResult { - data class Found(val code: ScannableKikCode) : 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 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 index 4a86b09460..ef552e4595 100644 --- a/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzerImpl.kt +++ b/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzerImpl.kt @@ -42,7 +42,9 @@ class StaticImageAnalyzerImpl @Inject constructor( if (started.elapsedNow() > budget) return StaticImageResult.Exhausted val scanned = scan(bitmap, candidate) - if (scanned != null) return StaticImageResult.Found(scanned) + if (scanned != null) { + return StaticImageResult.Found(scanned, candidate.tier, candidate.zoom) + } } return StaticImageResult.NotFound From 4519ebacd7ce98653399489115b492ee15174108 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 22:58:25 -0400 Subject: [PATCH 11/19] test(scanner): make the later-tier scene actually need a later tier --- .../kotlin/com/kik/scan/StillImageScanTest.kt | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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 index e55e9fec1e..315fda7c2b 100644 --- a/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/StillImageScanTest.kt +++ b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/StillImageScanTest.kt @@ -36,13 +36,19 @@ class StillImageScanTest { 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 = 1600, - height = 1200, + width = 3200, + height = 2400, codeSide = 260, left = 120, top = 700, @@ -51,6 +57,12 @@ class StillImageScanTest { 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 @@ -124,7 +136,9 @@ class StillImageScanTest { val luminance = LuminancePlane.fromArgb(pixels, width, height) val scanned = scanner.scanKikCode(luminance, width, height).getOrNull() - if (scanned != null) return StaticImageResult.Found(scanned) + if (scanned != null) { + return StaticImageResult.Found(scanned, candidate.tier, candidate.zoom) + } } return StaticImageResult.NotFound From 78fa05046e2f1f5cc85f92d5e9772b3a47dae0bb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 23:14:06 -0400 Subject: [PATCH 12/19] fix(scanner): decode picked images off the main thread and under the size cap Two decode bugs on the gallery path, both found in review. `StaticImageAnalyzerImpl` computed its sample size as `longestSide / MAX_SOURCE_SIDE` and then rounded that down to a power of two, so the guard only fired at 2x the cap and above. A 4000px photo, which is the example in the comment right above it, divided to 1 and was decoded at full size: 12M pixels into an IntArray per tier-1 pass, and a quadratic number of windows after it. Every image between 2400 and 4800 was untouched. It now rounds up to the smallest power of two that clears the cap, with the rule pinned by a test. `StaticQrAnalyzer.detect` was `suspend` but never left the caller's dispatcher, and its only caller reaches it from `rememberCoroutineScope`. So an arbitrary user photo was decoded at full resolution on the main thread, on the slow path, after the ladder had already spent its eight seconds. It now runs on `Dispatchers.Default` and applies the same cap. Its bitmap was also recycled in a `finally`, which cancellation reaches while ML Kit's worker still holds the `InputImage` wrapping it. Recycling from the listeners instead frees it once the task settles. `Exhausted`'s doc claimed it covered cancellation; `ensureActive` throws, so it never did. --- .../com/getcode/libs/qr/StaticQrAnalyzer.kt | 59 +++++++++++++------ .../kotlin/com/kik/scan/StillImageScanTest.kt | 29 +++++++++ .../com/getcode/media/StaticImageAnalyzer.kt | 2 +- .../getcode/media/StaticImageAnalyzerImpl.kt | 22 +++++-- 4 files changed, 88 insertions(+), 24 deletions(-) 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 index bf2403bdf1..fa1b3c4f58 100644 --- a/libs/quickresponse/src/main/kotlin/com/getcode/libs/qr/StaticQrAnalyzer.kt +++ b/libs/quickresponse/src/main/kotlin/com/getcode/libs/qr/StaticQrAnalyzer.kt @@ -9,7 +9,9 @@ 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 @@ -32,32 +34,55 @@ class StaticQrAnalyzer @Inject constructor( .build() ) - /** Every QR payload in the image, in ML Kit's order. */ - suspend fun detect(uri: Uri): List { - val bitmap = runCatching { - ImageDecoder.decodeBitmap( - ImageDecoder.createSource(context.contentResolver, uri) - ) { decoder, _, _ -> - decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE - decoder.isMutableRequired = false - } - }.getOrNull() ?: return emptyList() - - return try { - detect(bitmap) - } finally { - bitmap.recycle() - } + /** + * 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) } - private suspend fun detect(bitmap: Bitmap): List = + /** 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/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/StillImageScanTest.kt b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/StillImageScanTest.kt index 315fda7c2b..a1c079d7a6 100644 --- a/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/StillImageScanTest.kt +++ b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/StillImageScanTest.kt @@ -9,6 +9,7 @@ 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 @@ -186,4 +187,32 @@ class StillImageScanTest { /** 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 8a71834933..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 @@ -19,7 +19,7 @@ sealed interface StaticImageResult { /** The ladder was walked to the end and nothing decoded. */ data object NotFound : StaticImageResult - /** The budget expired or the caller cancelled. Not the same as [NotFound]. */ + /** The budget expired before the ladder ended. Not the same as [NotFound]. */ data object Exhausted : 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 index ef552e4595..6d28366ea6 100644 --- a/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzerImpl.kt +++ b/vendor/kik/scanner/src/main/kotlin/com/getcode/media/StaticImageAnalyzerImpl.kt @@ -96,14 +96,24 @@ class StaticImageAnalyzerImpl @Inject constructor( // 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) - if (longestSide > MAX_SOURCE_SIDE) { - val sample = longestSide / MAX_SOURCE_SIDE - decoder.setTargetSampleSize(Integer.highestOneBit(sample)) - } + decoder.setTargetSampleSize(sampleSizeFor(longestSide)) } }.getOrNull() - private companion object { + 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 @@ -111,6 +121,6 @@ class StaticImageAnalyzerImpl @Inject constructor( */ val BUDGET: Duration = 8.seconds - const val MAX_SOURCE_SIDE = 2400 + internal const val MAX_SOURCE_SIDE = 2400 } } From 4085bd4372a5baa30ffc33ceeb39791096807834 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 23:14:06 -0400 Subject: [PATCH 13/19] fix(scanner): keep the new strings where the app keeps its strings The feature module had grown a `strings.xml` of its own, the only one under `apps/flipcash/features`. Nothing localized would have picked it up. Three of its four strings were duplicates: `error_title_noCodeFound`, `error_description_noCodeFound`, `error_title_cashExpired` and `error_description_cashExpired` already exist in `ui/resources`, already translated. The gallery button's content description is genuinely new, so it goes in `apps/flipcash/core`, next to the other `content_description_*` entries. --- apps/flipcash/core/src/main/res/values/strings.xml | 1 + .../features/scanner/src/main/res/values/strings.xml | 12 ------------ 2 files changed, 1 insertion(+), 12 deletions(-) delete mode 100644 apps/flipcash/features/scanner/src/main/res/values/strings.xml 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/features/scanner/src/main/res/values/strings.xml b/apps/flipcash/features/scanner/src/main/res/values/strings.xml deleted file mode 100644 index ae3cf3aec1..0000000000 --- a/apps/flipcash/features/scanner/src/main/res/values/strings.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - Scan a code from a photo - - No Code Found - We couldn\'t find a code in that photo. - - Cash Code Expired - That cash code isn\'t live any more. Ask them to show it on their screen while you scan. - - From fd61997b24f917532e8ed8d2cd73d56d9888152a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 23:14:06 -0400 Subject: [PATCH 14/19] fix(scanner): gate the expired-cash banner on the scan's origin The scanner held a boolean saying a grab from a picked photo was in flight, and showed the banner only while it was set. It was cleared in two places: a bill arriving, and the event itself. But `onCodeScan` returns silently at five points and `attemptGrab` fails for reasons other than a missing give request, so the flag survived all of them. The concrete case is a photo of a tip card. It routes to `onTipCardScanned`, which produces neither a bill nor the event, and the flag stays set for the life of the composition. The next time the camera hits `GiveRequestNotFound` the user gets "Cash Expired" over a code they are still pointing at, which is the exact false positive `CodeScanEvent`'s own doc says the camera must not produce. `onCodeScan` now takes `fromStillImage`, and the delegate raises the event only for those scans. The camera passes nothing and cannot surface a photo's error. The scanner keeps no flag, so there is no state left to leak. Also drops `scanJob` out of snapshot state. Only the cancel lambda reads it, and writing it recomposed the whole scanner. --- .../flipcash/app/scanner/internal/Scanner.kt | 40 ++++++++----------- .../flipcash/app/session/SessionController.kt | 18 ++++++--- .../internal/delegates/CodeScanDelegate.kt | 13 +++--- 3 files changed, 37 insertions(+), 34 deletions(-) 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 34f34ae21d..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 @@ -58,6 +58,7 @@ 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 @@ -94,11 +95,10 @@ internal fun Scanner() { val staticQrAnalyzer = remember(context) { StaticQrAnalyzer(context.applicationContext) } var isScanningStillImage by remember { mutableStateOf(false) } - var scanJob by remember { mutableStateOf(null) } - // A grab started from a picked photo, still waiting on the server. The camera raises - // `CashCodeNotLive` too and is meant to ignore it, so the flag is what separates the two. - var awaitingStillImageGrab 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) @@ -149,15 +149,15 @@ internal fun Scanner() { val showNoCodeFound = { TopBarManager.showMessage( - title = resources.getString(R.string.error_title_noCodeInPhoto), - message = resources.getString(R.string.error_description_noCodeInPhoto), + title = resources.getString(R.string.error_title_noCodeFound), + message = resources.getString(R.string.error_description_noCodeFound), ) } val onImagePicked = { uri: Uri -> - scanJob?.cancel() + scanJob.getAndSet(null)?.cancel() analytics.galleryImagePicked() - scanJob = scope.launch { + scanJob.set(scope.launch { isScanningStillImage = true val started = TimeSource.Monotonic.markNow() try { @@ -172,8 +172,7 @@ internal fun Scanner() { // 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. - awaitingStillImageGrab = true - session.onCodeScan(result.code) + 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. @@ -197,7 +196,7 @@ internal fun Scanner() { } finally { isScanningStillImage = false } - } + }) Unit } @@ -217,15 +216,12 @@ internal fun Scanner() { LaunchedEffect(session) { session.codeScanEvents.collect { event -> when (event) { - CodeScanEvent.CashCodeNotLive -> { - if (awaitingStillImageGrab) { - awaitingStillImageGrab = false - TopBarManager.showMessage( - title = resources.getString(R.string.error_title_cashCodeNotLive), - message = resources.getString(R.string.error_description_cashCodeNotLive), - ) - } - } + // 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), + ) } } } @@ -276,7 +272,7 @@ internal fun Scanner() { ) if (isScanningStillImage) { - StillImageScanOverlay(onCancel = { scanJob?.cancel() }) + StillImageScanOverlay(onCancel = { scanJob.getAndSet(null)?.cancel() }) } } @@ -292,8 +288,6 @@ internal fun Scanner() { LaunchedEffect(billState.bill) { if (billState.bill != null) { - // The grab landed, so there is nothing left to explain. - awaitingStillImageGrab = false navigator.hide() } } 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 400246fff2..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 @@ -47,10 +47,10 @@ sealed interface CodeScanEvent { * A decoded cash code had no give request behind it — whoever showed it is no longer showing * it. * - * The camera path ignores this, and should: 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 the gallery path is the only caller - * that has to say why nothing happened. + * 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 } @@ -63,7 +63,15 @@ interface CodeScanOperations { 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 72fa72fa9e..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 @@ -87,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 } @@ -122,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( @@ -177,8 +177,9 @@ class CodeScanDelegate @Inject constructor( // 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. Only the still-image path listens. - if (it is GrabTransactorError.GiveRequestNotFound) { + // 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) } } From 2828dec6599049fc88d412dc6514213aff870d5c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 23:14:06 -0400 Subject: [PATCH 15/19] fix(scanner): stop resubscribing to the gallery flag on every recomposition `observe` builds a fresh `stateIn(dataScope, Eagerly, ..)` per call, and `dataScope` is singleton-scoped, so each returned flow starts a DataStore collection that nothing cancels. Calling it straight from the composable body leaked one per recomposition, and `collectAsStateWithLifecycle` restarted its own collection too, since the flow identity is a remember key. This is the Scan tab, which recomposes on the camera preview state, the bill state and the still-image search, so the leak compounds. Remembering the flow against the controller is the fix at this call site; caching per flag inside the controller would fix the pattern everywhere and is a separate change. --- .../app/scanner/internal/bills/ScannableContainer.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 6896f83be6..c52e295a03 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 @@ -115,9 +115,14 @@ internal fun ScannableContainer( } } - val galleryEnabled by LocalFeatureFlags.current - .observe(FeatureFlag.ScanFromGallery) - .collectAsStateWithLifecycle() + // 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 only while a bill is up, From 1c3e3e347d69e23cbe0c7fd69714fe7bee68aa4b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 23:14:06 -0400 Subject: [PATCH 16/19] fix(ui): restart KeepScreenOn when the brightness boost is toggled The effect reads `useBrightness` when it applies its overrides, but keys only on `isEnabled` and the lifecycle owner, and does not wrap it in `rememberUpdatedState`. A caller that turns the boost on while `isEnabled` stays true keeps the old behaviour with no sign of it. Nothing hits this today, because every current caller dips `isEnabled` through false first. The scanner is one edit away from not doing that. --- .../src/main/kotlin/com/getcode/ui/utils/KeepScreenOn.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 9733484bf006d3fa56c2e53b80cecc1e3e3a0fab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 23:14:07 -0400 Subject: [PATCH 17/19] chore(scanner): declare the vendor scanner dependency directly `Scanner.kt` names `KikCodeScannerImpl`, `StaticImageAnalyzerImpl` and `KikCodeAnalyzer`. It compiled only because `:ui:scanner` exposes them through `api`, so the module's own build file did not say what it uses. --- apps/flipcash/features/scanner/build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/flipcash/features/scanner/build.gradle.kts b/apps/flipcash/features/scanner/build.gradle.kts index a36927e66f..86bf4fe7ac 100644 --- a/apps/flipcash/features/scanner/build.gradle.kts +++ b/apps/flipcash/features/scanner/build.gradle.kts @@ -24,6 +24,9 @@ dependencies { 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) } From 52b257bf0c36c97b75ec722300b91ab29edf4ff6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 20 Sep 2026 12:09:33 -0400 Subject: [PATCH 18/19] feat(nav): let a tab home hang a control off the tab bar's row The gallery entry point floated over the camera preview at the bottom-left, so it sat on top of the scanner's own content with nothing behind it. It now sits on the bar's row beside the pill and takes width from it. `NavigationBar` gains a `trailing` slot in the same shape as `avatar`: the caller supplies content, the bar supplies a modifier already carrying the pill's size and frosted fill, so the control cannot drift from the pill next to it. Screens reach the slot through `TabBarAction(tab) { .. }`, published into a controller held beside the bar in `AppContent`. The slot is keyed to the publishing tab, not to composition lifetime. A tab press swaps the backstack but leaves the outgoing screen composed until the route transition ends, so a dispose-driven clear left the control on the bar for a beat after the indicator had moved. `actionFor(selectedTab)` flips on the press itself, and the slot's enter/exit shares the indicator's spring. The scanner drops its own haze source and its `billState` read: it borrows the bar's blur through the slot modifier, and the bar already hides whole while a bill is up. The glyph is outline at 25dp, which matches the ink of the unselected tabs rather than their 30dp box; full opacity is what separates it from them. --- .../app/internal/ui/AppNavigationBar.kt | 5 + .../app/internal/ui/navigation/AppContent.kt | 8 + .../com/flipcash/app/core/ui/NavigationBar.kt | 255 ++++++++++++------ .../app/core/navigation/TabBarAction.kt | 78 ++++++ .../app/scanner/internal/GalleryScanButton.kt | 35 ++- .../internal/bills/ScannableContainer.kt | 38 +-- 6 files changed, 292 insertions(+), 127 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarAction.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index b51a91279b..108b3c3c68 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -22,6 +22,7 @@ import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.core.AppRoute import com.flipcash.app.core.LocalUserManager import dev.chrisbanes.haze.HazeState +import com.flipcash.app.core.navigation.LocalTabBarAction import com.flipcash.app.core.navigation.NavBarButton import com.flipcash.app.core.navigation.asNavBarTab import com.flipcash.app.core.navigation.destinationRoute @@ -133,6 +134,10 @@ internal fun AppNavigationBar( }, hazeState = hazeState, avatar = avatar, + // Whatever the open tab home published, composed here rather than in the + // screen: that is what puts it in the bar's row and lets it share the bar's + // haze source instead of standing up a second one. + trailing = LocalTabBarAction.current.actionFor(state.selectedTab), ) } } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 760ab37a94..9b26d6e893 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -29,8 +29,10 @@ import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.cardexpand.LocalCardExpansion import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkAction +import com.flipcash.app.core.navigation.LocalTabBarAction import com.flipcash.app.core.navigation.LocalTabBarVisibility import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.core.navigation.TabBarActionController import com.flipcash.app.core.navigation.TabBarVisibilityController import com.flipcash.app.core.navigation.asNavBarTab import com.flipcash.app.core.navigation.destinationRoute @@ -91,6 +93,11 @@ internal fun AppContent( // full screen in place, so there's no route change for the visibility rule below to notice. val tabBarVisibility = remember { TabBarVisibilityController() } + // And lets one hang a control off the end of the bar's row. Held here, beside the bar, because + // the control is composed by the bar: a screen-owned one would float over the content instead + // of taking width from the pill, and would need a haze source of its own. + val tabBarAction = remember { TabBarActionController() } + // A tab press replaces the whole back stack (tab-bar semantics — see AppNavigationBar), so every // tab home was destroyed and rebuilt on each switch: the wallet re-fetched its balances and the // chat list scrolled back to the top. Hold each tab home's ViewModels and scroll state outside the @@ -117,6 +124,7 @@ internal fun AppContent( CompositionLocalProvider( LocalCardExpansion provides cardExpansion, LocalTabBarVisibility provides tabBarVisibility, + LocalTabBarAction provides tabBarAction, ) { Box(modifier = Modifier.fillMaxSize()) { // Mark the nav content as the haze source so the frosted bar blurs whatever scrolls beneath it. diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index d9a1aa7ec0..f15ec4e914 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -1,9 +1,16 @@ package com.flipcash.app.core.ui +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -24,8 +31,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -96,12 +105,21 @@ fun NavigationBar( // avatar and passes null when there is no photo. The modifier handed back already sizes, clips // and fades the slot; the avatar only has to fill it. avatar: (@Composable (Modifier) -> Unit)? = null, + // A tab home can hang one control off the end of the row (see `TabBarAction`). The pill yields + // the width rather than the control floating over the content, so nothing is ever covered. Like + // `avatar`, the modifier handed back is already sized and already carries the pill's own frosted + // fill -- the same chain, so the two surfaces cannot drift apart. + trailing: (@Composable (Modifier) -> Unit)? = null, ) { val order = NavBarButton.tabs if (order.isEmpty()) return val iconSize = CodeTheme.dimens.staticGrid.x6 val itemHeight = iconSize + CodeTheme.dimens.staticGrid.x2 * 2 + // The trailing control is the pill's full height made round, so the two read as one row of glass + // rather than a button parked next to a bar. Derived from the pill's own geometry -- item box + // plus the pill's inner padding -- so it tracks any change to either. + val actionSize = itemHeight + CodeTheme.dimens.grid.x1 * 2 val selectedIndex = order.indexOf(state.selectedTab) .takeIf { it >= 0 && it <= order.lastIndex } ?: order.indexOf(NavBarButton.Wallet) @@ -137,110 +155,146 @@ fun NavigationBar( .then(pillFill) .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), CircleShape) - BoxWithConstraints( + Row( modifier = Modifier .fillMaxWidth() - .then(modifier) - .then(pillBackground) - .padding(CodeTheme.dimens.grid.x1), + .then(modifier), + verticalAlignment = Alignment.CenterVertically, ) { - val itemWidth = maxWidth / order.size - - // Selected-state pill that slides to the active tab, drawn behind the icons. - val indicatorOffset by animateDpAsState( - targetValue = itemWidth * selectedIndex, - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - label = "navBarIndicatorOffset", - ) - Box( + BoxWithConstraints( modifier = Modifier - .offset { IntOffset(indicatorOffset.roundToPx(), 0) } - .width(itemWidth) - .height(itemHeight) - .background(Color.White.copy(alpha = 0.2f), CircleShape), - ) + .weight(1f) + .then(pillBackground) + .padding(CodeTheme.dimens.grid.x1), + ) { + val itemWidth = maxWidth / order.size - Row(modifier = Modifier.fillMaxWidth()) { - order.fastForEach { button -> - val selected = button == state.selectedTab - val iconAlpha by animateFloatAsState( - targetValue = if (selected) 1f else 0.5f, - label = "navBarIconAlpha", - ) - val badgeCount = state.badgeCount(button) - Box( - modifier = Modifier - .weight(1f) - .height(itemHeight) - .testTag(button.testTag) - // Deliberately unclipped: the unread badge overhangs the icon's top-right - // corner and a clip would shave it. Safe because the click indication is - // null, so there is no ripple that needs bounding. - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { onButtonClick(button) }, - contentAlignment = Alignment.Center, - ) { - Box { - if (button == NavBarButton.TipCard && avatar != null) { - // The photo slot has its own unselected state (node 9713:664): the ring - // thins from 2dp to 1dp and drops to white at 50%, which the slot's - // 0.5 alpha then halves again. The photo itself keeps its size — the - // ring is inset, and the padding around it does not change. - val ringWidth by animateDpAsState( - targetValue = if (selected) { - CodeTheme.dimens.thickBorder - } else { - CodeTheme.dimens.border - }, - label = "navBarAvatarRingWidth", - ) - Box( - modifier = Modifier - .size(iconSize) - .graphicsLayer { alpha = iconAlpha } - .padding(CodeTheme.dimens.thickBorder), - ) { - avatar(Modifier.fillMaxSize().clip(CircleShape)) - // Drawn over the photo rather than behind it, so the ring survives - // whatever background the avatar paints for itself. iconAlpha is - // the ring's own fade, on top of the slot's — the two compose to - // the 25% the unselected ring reads at. + // Selected-state pill that slides to the active tab, drawn behind the icons. + val indicatorOffset by animateDpAsState( + targetValue = itemWidth * selectedIndex, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "navBarIndicatorOffset", + ) + Box( + modifier = Modifier + .offset { IntOffset(indicatorOffset.roundToPx(), 0) } + .width(itemWidth) + .height(itemHeight) + .background(Color.White.copy(alpha = 0.2f), CircleShape), + ) + + Row(modifier = Modifier.fillMaxWidth()) { + order.fastForEach { button -> + val selected = button == state.selectedTab + val iconAlpha by animateFloatAsState( + targetValue = if (selected) 1f else 0.5f, + label = "navBarIconAlpha", + ) + val badgeCount = state.badgeCount(button) + Box( + modifier = Modifier + .weight(1f) + .height(itemHeight) + .testTag(button.testTag) + // Deliberately unclipped: the unread badge overhangs the icon's top-right + // corner and a clip would shave it. Safe because the click indication is + // null, so there is no ripple that needs bounding. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onButtonClick(button) }, + contentAlignment = Alignment.Center, + ) { + Box { + if (button == NavBarButton.TipCard && avatar != null) { + // The photo slot has its own unselected state (node 9713:664): the ring + // thins from 2dp to 1dp and drops to white at 50%, which the slot's + // 0.5 alpha then halves again. The photo itself keeps its size — the + // ring is inset, and the padding around it does not change. + val ringWidth by animateDpAsState( + targetValue = if (selected) { + CodeTheme.dimens.thickBorder + } else { + CodeTheme.dimens.border + }, + label = "navBarAvatarRingWidth", + ) Box( modifier = Modifier - .fillMaxSize() - .border( - ringWidth, - Color.White.copy(alpha = iconAlpha), - CircleShape, - ), + .size(iconSize) + .graphicsLayer { alpha = iconAlpha } + .padding(CodeTheme.dimens.thickBorder), + ) { + avatar(Modifier.fillMaxSize().clip(CircleShape)) + // Drawn over the photo rather than behind it, so the ring survives + // whatever background the avatar paints for itself. iconAlpha is + // the ring's own fade, on top of the slot's — the two compose to + // the 25% the unselected ring reads at. + Box( + modifier = Modifier + .fillMaxSize() + .border( + ringWidth, + Color.White.copy(alpha = iconAlpha), + CircleShape, + ), + ) + } + } else { + Image( + modifier = Modifier + .size(iconSize) + .graphicsLayer { alpha = iconAlpha }, + painter = button.icon(selected), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, ) } - } else { - Image( + // Overlaps the glyph's top-right corner (matching the iOS bar) rather than + // floating detached above it. Full opacity regardless of tab selection — + // the count must stay readable on an unselected tab. + Badge( modifier = Modifier - .size(iconSize) - .graphicsLayer { alpha = iconAlpha }, - painter = button.icon(selected), - colorFilter = ColorFilter.tint(Color.White), - contentDescription = null, + .align(Alignment.TopEnd) + .offset(x = CodeTheme.dimens.staticGrid.x1, y = -CodeTheme.dimens.staticGrid.x1), + count = badgeCount, + color = CodeTheme.colors.indicator, ) } - // Overlaps the glyph's top-right corner (matching the iOS bar) rather than - // floating detached above it. Full opacity regardless of tab selection — - // the count must stay readable on an unselected tab. - Badge( - modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = CodeTheme.dimens.staticGrid.x1, y = -CodeTheme.dimens.staticGrid.x1), - count = badgeCount, - color = CodeTheme.colors.indicator, - ) } } } } + + // Kept so the outgoing control still has something to draw while it shrinks away; by the + // time `trailing` goes null the publisher has already left the tree. + var retained by remember { mutableStateOf<(@Composable (Modifier) -> Unit)?>(trailing) } + if (trailing != null) retained = trailing + + AnimatedVisibility( + visible = trailing != null, + // The width change is the whole point: the pill has `weight(1f)`, so whatever this + // animation gives up or takes, the pill absorbs, and the tabs (and the indicator that + // tracks them) resize with it. Both run on the same spring as the indicator itself so + // they settle together instead of the indicator chasing a target that is still moving. + enter = expandHorizontally( + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + expandFrom = Alignment.End, + ) + fadeIn() + scaleIn(initialScale = 0.6f), + exit = shrinkHorizontally( + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + shrinkTowards = Alignment.End, + ) + fadeOut() + scaleOut(targetScale = 0.6f), + ) { + // The gap is inside the animated element so it collapses with it; as a padding on the + // pill it would leave a permanent notch on tabs that publish nothing. + retained?.invoke( + Modifier + .padding(start = CodeTheme.dimens.grid.x2) + .size(actionSize) + .then(pillBackground) + ) + } } } @@ -335,3 +389,28 @@ private fun NavigationBarNoAvatarSelectedPreview() { onButtonClick = { }, ) } + +/** + * The bar with a tab home's trailing control published (node 10000:111297 has no such state — this + * is the Scan tab's gallery entry point). The pill is narrower here than in every preview above, + * which is the point: the control takes the width rather than floating over the screen. + */ +@Preview(name = "Trailing action") +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun NavigationBarTrailingPreview() { + NavigationBar( + state = rememberNavigationBarState(selectedTab = NavBarButton.Scanner), + onButtonClick = { }, + trailing = { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { + Image( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), + painter = painterResource(R.drawable.ic_nav_tipcard), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + } + }, + ) +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarAction.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarAction.kt new file mode 100644 index 0000000000..fce559650f --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarAction.kt @@ -0,0 +1,78 @@ +package com.flipcash.app.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier + +/** + * A control published by [tab]'s home for the tab bar to draw beside the pill. + * + * The owning tab rides along because composition lifetime is the wrong clock for this. A tab press + * swaps the backstack, but the outgoing screen stays composed until the route transition finishes, + * so waiting for its dispose leaves the control on the bar for a beat after the indicator has + * already moved. [tab] changes on the press itself, so the bar can start shrinking in step with it. + */ +@Immutable +class TabBarActionEntry( + val tab: NavBarButton, + val content: @Composable (Modifier) -> Unit, +) + +/** + * Lets a tab home hang one control off the end of the tab bar's row, beside the pill. + * + * The bar is root chrome and knows nothing about any screen, so the screen publishes here and the + * bar renders what it finds. The control is handed a [Modifier] that already carries the pill's own + * size and frosted fill — the same chain, not a copy — so the two cannot drift apart, and the + * screen supplies only the click and the glyph. This is the same slot shape as the bar's `avatar`. + * + * Holding a `@Composable` lambda in state is deliberate: the control is composed in the *bar's* + * composition, which is what puts it in the bar's row and lets it share the bar's haze source. + */ +@Stable +class TabBarActionController { + var published: TabBarActionEntry? by mutableStateOf(null) + private set + + fun set(entry: TabBarActionEntry) { + published = entry + } + + /** + * Clears [entry] only if it is still the published one. Two screens overlap while a route + * transition runs, so the outgoing screen's dispose lands *after* the incoming screen has + * published; an unconditional clear would blank the incoming screen's control. + */ + fun clear(entry: TabBarActionEntry) { + if (published === entry) published = null + } + + /** The control to draw while [selectedTab] is the open tab, or null if that tab published none. */ + fun actionFor(selectedTab: NavBarButton): (@Composable (Modifier) -> Unit)? = + published?.takeIf { it.tab == selectedTab }?.content +} + +val LocalTabBarAction = staticCompositionLocalOf { TabBarActionController() } + +/** Publishes [content] into the tab bar's trailing slot for as long as [tab] is the open tab. */ +@Composable +fun TabBarAction(tab: NavBarButton, content: @Composable (Modifier) -> Unit) { + val controller = LocalTabBarAction.current + // The caller's lambda is a fresh instance on every recomposition. Publishing that directly would + // re-enter the DisposableEffect each time, so publish one stable entry and let it read through + // to the latest content. + val latest by rememberUpdatedState(content) + val entry = remember(tab) { TabBarActionEntry(tab) { modifier -> latest(modifier) } } + DisposableEffect(controller, entry) { + controller.set(entry) + onDispose { controller.clear(entry) } + } +} 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 index 74184af670..ce21a3f4a4 100644 --- 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 @@ -4,26 +4,31 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PhotoLibrary import com.flipcash.features.scanner.R -import com.getcode.ui.core.unboundedClickable +import com.getcode.theme.CodeTheme /** - * The Scan tab's gallery entry point. + * The Scan tab's gallery entry point, seated on the tab bar's row beside the pill. * * `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. + * + * [modifier] arrives from the bar's trailing slot already sized and already carrying the pill's + * frosted fill, so this draws only the glyph and the click. Nothing here decides how the control + * looks against the pill, which is the only way the two can be guaranteed to match. */ @Composable internal fun GalleryScanButton( @@ -36,13 +41,8 @@ internal fun GalleryScanButton( Box( modifier = modifier - // A 44dp target around a 32dp glyph: the glyph matches the tab bar's icon size so the - // control reads as the same weight as a tab, and the target is the minimum that is - // comfortable to hit at the bottom corner. The ripple is unbounded so it is not - // clipped to that box -- the glyph sits on the camera preview with no surface behind - // it, and a square ripple there would read as a button that is not drawn. - .size(44.dp) - .unboundedClickable { + .testTag("scanner_gallery_button") + .clickable { launcher.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) ) @@ -50,10 +50,17 @@ internal fun GalleryScanButton( contentAlignment = Alignment.Center, ) { Icon( - imageVector = Icons.Default.PhotoLibrary, + // A step under the bar's own 30dp icon box because this glyph fills its viewport + // edge to edge, where the tab drawables carry padding inside theirs. Matching the box + // drew a visibly heavier icon than the tabs beside it; matching the ink reads level. + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + // Outline, like the bar's unselected tabs: the filled twin is what a tab wears when it + // IS the open tab, and this is an action with no selected state to claim. It stays at + // full opacity where an unselected tab sits at half, which is the distinction that + // matters -- same weight, brighter, so it reads as available rather than as a tab. + imageVector = Icons.Outlined.PhotoLibrary, contentDescription = stringResource(R.string.content_description_scanFromGallery), tint = Color.White, - modifier = Modifier.size(32.dp), ) } } 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 c52e295a03..3536270980 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 @@ -3,22 +3,18 @@ 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.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.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.android.extensions.launchAppSettings -import com.flipcash.app.core.navigation.LocalTabBarPadding +import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.core.navigation.TabBarAction import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.scanner.internal.GalleryScanButton @@ -28,7 +24,6 @@ import com.flipcash.features.scanner.R import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager 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 @@ -73,7 +68,6 @@ internal fun ScannableContainer( } val state by session.state.collectAsStateWithLifecycle() - val billState by session.billState.collectAsStateWithLifecycle() Box( modifier = Modifier @@ -124,23 +118,17 @@ internal fun ScannableContainer( } 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 only while a bill is up, - // because a bill is drawn at the app root over this container and the glyph would sit on - // top of it. - if (galleryEnabled && billState.bill == null) { - GalleryScanButton( - onImagePicked = onImagePicked, - modifier = Modifier - .align(Alignment.BottomStart) - // `LocalTabBarPadding` is the space the hoisted tab bar occupies, provided per - // nav entry by `NavTabBarInsetEntryDecorator`. Reading it rather than hard-coding - // a height is what keeps the glyph clear of the pill when the bar resizes. - .padding( - start = 20.dp, - bottom = LocalTabBarPadding.current.calculateBottomPadding() + 16.dp, - ) - ) + // Published to the tab bar rather than drawn here, so it sits on the bar's row beside the + // pill and takes width from it instead of floating over the preview. Nothing needs to be + // said about a presented bill: the bar hides itself whole while one is up, and this goes + // with it. + // + // Outside the permission `when` on purpose: a picked photo needs no camera, and someone who + // declined the camera is exactly who this is for. + if (galleryEnabled) { + TabBarAction(NavBarButton.Scanner) { modifier -> + GalleryScanButton(onImagePicked = onImagePicked, modifier = modifier) + } } } } From 868f45a5dd74b211917454c9c9ad6ec20363ea82 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 21 Sep 2026 10:32:15 -0400 Subject: [PATCH 19/19] fix(scanner): move the gallery button to the scanner's top right The button sat in a slot on the tab bar's row, which only worked because the bar is ours to lay out. iOS has no way to put a control there: no API shrinks the system tab bar for a non-tab control, and the bar spans 21.0-380.7pt of a 402pt screen on an iPhone 17 Pro, so there is no gap to sit in. `tabViewBottomAccessory` renders above the bar, and the inline beside-the-bar placement exists only alongside a scroll-collapsed bar, where collapse is scroll-driven and not settable. A layout limit, not an animation one. The corner is a placement both platforms can hold, and is where iOS already had it -- `GalleryScanButton.swift` has been top-trailing all along, so this converges the two rather than compromising between them. This reverts the `trailing` slot and the `TabBarAction` controller that fed it (52b257bf0). `NavigationBar` goes back to a plain pill, and nothing outside the scanner knows the gallery button exists. The button is a `CircularIconButton` again rather than a bare glyph, because the backdrop is now whatever the camera sees: a white icon alone has no contrast it can count on. The scanner keeps its own haze source for that, since the app-root source wraps `AppNavHost` and this screen is inside it -- a blur hung off it would sample a layer containing itself. The source covers the preview only, with the button outside it. Corrects the comment on the bill gate while it moves. The claim was that the glyph would sit on top of a presented bill; `NavBillOverlayEntryDecorator` draws the bill after this entry's content, so the bill covers the button. The gate stays, to avoid leaving a live control composed under a modal scrim. --- .../app/internal/ui/AppNavigationBar.kt | 5 - .../app/internal/ui/navigation/AppContent.kt | 8 - .../com/flipcash/app/core/ui/NavigationBar.kt | 255 ++++++------------ .../app/core/navigation/TabBarAction.kt | 78 ------ .../features/scanner/build.gradle.kts | 3 + .../app/scanner/internal/GalleryScanButton.kt | 54 ++-- .../internal/bills/ScannableContainer.kt | 97 ++++--- 7 files changed, 178 insertions(+), 322 deletions(-) delete mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarAction.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index 108b3c3c68..b51a91279b 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -22,7 +22,6 @@ import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.core.AppRoute import com.flipcash.app.core.LocalUserManager import dev.chrisbanes.haze.HazeState -import com.flipcash.app.core.navigation.LocalTabBarAction import com.flipcash.app.core.navigation.NavBarButton import com.flipcash.app.core.navigation.asNavBarTab import com.flipcash.app.core.navigation.destinationRoute @@ -134,10 +133,6 @@ internal fun AppNavigationBar( }, hazeState = hazeState, avatar = avatar, - // Whatever the open tab home published, composed here rather than in the - // screen: that is what puts it in the bar's row and lets it share the bar's - // haze source instead of standing up a second one. - trailing = LocalTabBarAction.current.actionFor(state.selectedTab), ) } } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 9b26d6e893..760ab37a94 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -29,10 +29,8 @@ import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.cardexpand.LocalCardExpansion import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkAction -import com.flipcash.app.core.navigation.LocalTabBarAction import com.flipcash.app.core.navigation.LocalTabBarVisibility import com.flipcash.app.core.navigation.NavBarButton -import com.flipcash.app.core.navigation.TabBarActionController import com.flipcash.app.core.navigation.TabBarVisibilityController import com.flipcash.app.core.navigation.asNavBarTab import com.flipcash.app.core.navigation.destinationRoute @@ -93,11 +91,6 @@ internal fun AppContent( // full screen in place, so there's no route change for the visibility rule below to notice. val tabBarVisibility = remember { TabBarVisibilityController() } - // And lets one hang a control off the end of the bar's row. Held here, beside the bar, because - // the control is composed by the bar: a screen-owned one would float over the content instead - // of taking width from the pill, and would need a haze source of its own. - val tabBarAction = remember { TabBarActionController() } - // A tab press replaces the whole back stack (tab-bar semantics — see AppNavigationBar), so every // tab home was destroyed and rebuilt on each switch: the wallet re-fetched its balances and the // chat list scrolled back to the top. Hold each tab home's ViewModels and scroll state outside the @@ -124,7 +117,6 @@ internal fun AppContent( CompositionLocalProvider( LocalCardExpansion provides cardExpansion, LocalTabBarVisibility provides tabBarVisibility, - LocalTabBarAction provides tabBarAction, ) { Box(modifier = Modifier.fillMaxSize()) { // Mark the nav content as the haze source so the frosted bar blurs whatever scrolls beneath it. diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index f15ec4e914..d9a1aa7ec0 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -1,16 +1,9 @@ package com.flipcash.app.core.ui -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -31,10 +24,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -105,21 +96,12 @@ fun NavigationBar( // avatar and passes null when there is no photo. The modifier handed back already sizes, clips // and fades the slot; the avatar only has to fill it. avatar: (@Composable (Modifier) -> Unit)? = null, - // A tab home can hang one control off the end of the row (see `TabBarAction`). The pill yields - // the width rather than the control floating over the content, so nothing is ever covered. Like - // `avatar`, the modifier handed back is already sized and already carries the pill's own frosted - // fill -- the same chain, so the two surfaces cannot drift apart. - trailing: (@Composable (Modifier) -> Unit)? = null, ) { val order = NavBarButton.tabs if (order.isEmpty()) return val iconSize = CodeTheme.dimens.staticGrid.x6 val itemHeight = iconSize + CodeTheme.dimens.staticGrid.x2 * 2 - // The trailing control is the pill's full height made round, so the two read as one row of glass - // rather than a button parked next to a bar. Derived from the pill's own geometry -- item box - // plus the pill's inner padding -- so it tracks any change to either. - val actionSize = itemHeight + CodeTheme.dimens.grid.x1 * 2 val selectedIndex = order.indexOf(state.selectedTab) .takeIf { it >= 0 && it <= order.lastIndex } ?: order.indexOf(NavBarButton.Wallet) @@ -155,146 +137,110 @@ fun NavigationBar( .then(pillFill) .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), CircleShape) - Row( + BoxWithConstraints( modifier = Modifier .fillMaxWidth() - .then(modifier), - verticalAlignment = Alignment.CenterVertically, + .then(modifier) + .then(pillBackground) + .padding(CodeTheme.dimens.grid.x1), ) { - BoxWithConstraints( - modifier = Modifier - .weight(1f) - .then(pillBackground) - .padding(CodeTheme.dimens.grid.x1), - ) { - val itemWidth = maxWidth / order.size + val itemWidth = maxWidth / order.size - // Selected-state pill that slides to the active tab, drawn behind the icons. - val indicatorOffset by animateDpAsState( - targetValue = itemWidth * selectedIndex, - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - label = "navBarIndicatorOffset", - ) - Box( - modifier = Modifier - .offset { IntOffset(indicatorOffset.roundToPx(), 0) } - .width(itemWidth) - .height(itemHeight) - .background(Color.White.copy(alpha = 0.2f), CircleShape), - ) + // Selected-state pill that slides to the active tab, drawn behind the icons. + val indicatorOffset by animateDpAsState( + targetValue = itemWidth * selectedIndex, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "navBarIndicatorOffset", + ) + Box( + modifier = Modifier + .offset { IntOffset(indicatorOffset.roundToPx(), 0) } + .width(itemWidth) + .height(itemHeight) + .background(Color.White.copy(alpha = 0.2f), CircleShape), + ) - Row(modifier = Modifier.fillMaxWidth()) { - order.fastForEach { button -> - val selected = button == state.selectedTab - val iconAlpha by animateFloatAsState( - targetValue = if (selected) 1f else 0.5f, - label = "navBarIconAlpha", - ) - val badgeCount = state.badgeCount(button) - Box( - modifier = Modifier - .weight(1f) - .height(itemHeight) - .testTag(button.testTag) - // Deliberately unclipped: the unread badge overhangs the icon's top-right - // corner and a clip would shave it. Safe because the click indication is - // null, so there is no ripple that needs bounding. - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { onButtonClick(button) }, - contentAlignment = Alignment.Center, - ) { - Box { - if (button == NavBarButton.TipCard && avatar != null) { - // The photo slot has its own unselected state (node 9713:664): the ring - // thins from 2dp to 1dp and drops to white at 50%, which the slot's - // 0.5 alpha then halves again. The photo itself keeps its size — the - // ring is inset, and the padding around it does not change. - val ringWidth by animateDpAsState( - targetValue = if (selected) { - CodeTheme.dimens.thickBorder - } else { - CodeTheme.dimens.border - }, - label = "navBarAvatarRingWidth", - ) + Row(modifier = Modifier.fillMaxWidth()) { + order.fastForEach { button -> + val selected = button == state.selectedTab + val iconAlpha by animateFloatAsState( + targetValue = if (selected) 1f else 0.5f, + label = "navBarIconAlpha", + ) + val badgeCount = state.badgeCount(button) + Box( + modifier = Modifier + .weight(1f) + .height(itemHeight) + .testTag(button.testTag) + // Deliberately unclipped: the unread badge overhangs the icon's top-right + // corner and a clip would shave it. Safe because the click indication is + // null, so there is no ripple that needs bounding. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onButtonClick(button) }, + contentAlignment = Alignment.Center, + ) { + Box { + if (button == NavBarButton.TipCard && avatar != null) { + // The photo slot has its own unselected state (node 9713:664): the ring + // thins from 2dp to 1dp and drops to white at 50%, which the slot's + // 0.5 alpha then halves again. The photo itself keeps its size — the + // ring is inset, and the padding around it does not change. + val ringWidth by animateDpAsState( + targetValue = if (selected) { + CodeTheme.dimens.thickBorder + } else { + CodeTheme.dimens.border + }, + label = "navBarAvatarRingWidth", + ) + Box( + modifier = Modifier + .size(iconSize) + .graphicsLayer { alpha = iconAlpha } + .padding(CodeTheme.dimens.thickBorder), + ) { + avatar(Modifier.fillMaxSize().clip(CircleShape)) + // Drawn over the photo rather than behind it, so the ring survives + // whatever background the avatar paints for itself. iconAlpha is + // the ring's own fade, on top of the slot's — the two compose to + // the 25% the unselected ring reads at. Box( modifier = Modifier - .size(iconSize) - .graphicsLayer { alpha = iconAlpha } - .padding(CodeTheme.dimens.thickBorder), - ) { - avatar(Modifier.fillMaxSize().clip(CircleShape)) - // Drawn over the photo rather than behind it, so the ring survives - // whatever background the avatar paints for itself. iconAlpha is - // the ring's own fade, on top of the slot's — the two compose to - // the 25% the unselected ring reads at. - Box( - modifier = Modifier - .fillMaxSize() - .border( - ringWidth, - Color.White.copy(alpha = iconAlpha), - CircleShape, - ), - ) - } - } else { - Image( - modifier = Modifier - .size(iconSize) - .graphicsLayer { alpha = iconAlpha }, - painter = button.icon(selected), - colorFilter = ColorFilter.tint(Color.White), - contentDescription = null, + .fillMaxSize() + .border( + ringWidth, + Color.White.copy(alpha = iconAlpha), + CircleShape, + ), ) } - // Overlaps the glyph's top-right corner (matching the iOS bar) rather than - // floating detached above it. Full opacity regardless of tab selection — - // the count must stay readable on an unselected tab. - Badge( + } else { + Image( modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = CodeTheme.dimens.staticGrid.x1, y = -CodeTheme.dimens.staticGrid.x1), - count = badgeCount, - color = CodeTheme.colors.indicator, + .size(iconSize) + .graphicsLayer { alpha = iconAlpha }, + painter = button.icon(selected), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, ) } + // Overlaps the glyph's top-right corner (matching the iOS bar) rather than + // floating detached above it. Full opacity regardless of tab selection — + // the count must stay readable on an unselected tab. + Badge( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = CodeTheme.dimens.staticGrid.x1, y = -CodeTheme.dimens.staticGrid.x1), + count = badgeCount, + color = CodeTheme.colors.indicator, + ) } } } } - - // Kept so the outgoing control still has something to draw while it shrinks away; by the - // time `trailing` goes null the publisher has already left the tree. - var retained by remember { mutableStateOf<(@Composable (Modifier) -> Unit)?>(trailing) } - if (trailing != null) retained = trailing - - AnimatedVisibility( - visible = trailing != null, - // The width change is the whole point: the pill has `weight(1f)`, so whatever this - // animation gives up or takes, the pill absorbs, and the tabs (and the indicator that - // tracks them) resize with it. Both run on the same spring as the indicator itself so - // they settle together instead of the indicator chasing a target that is still moving. - enter = expandHorizontally( - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - expandFrom = Alignment.End, - ) + fadeIn() + scaleIn(initialScale = 0.6f), - exit = shrinkHorizontally( - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - shrinkTowards = Alignment.End, - ) + fadeOut() + scaleOut(targetScale = 0.6f), - ) { - // The gap is inside the animated element so it collapses with it; as a padding on the - // pill it would leave a permanent notch on tabs that publish nothing. - retained?.invoke( - Modifier - .padding(start = CodeTheme.dimens.grid.x2) - .size(actionSize) - .then(pillBackground) - ) - } } } @@ -389,28 +335,3 @@ private fun NavigationBarNoAvatarSelectedPreview() { onButtonClick = { }, ) } - -/** - * The bar with a tab home's trailing control published (node 10000:111297 has no such state — this - * is the Scan tab's gallery entry point). The pill is narrower here than in every preview above, - * which is the point: the control takes the width rather than floating over the screen. - */ -@Preview(name = "Trailing action") -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun NavigationBarTrailingPreview() { - NavigationBar( - state = rememberNavigationBarState(selectedTab = NavBarButton.Scanner), - onButtonClick = { }, - trailing = { modifier -> - Box(modifier, contentAlignment = Alignment.Center) { - Image( - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), - painter = painterResource(R.drawable.ic_nav_tipcard), - colorFilter = ColorFilter.tint(Color.White), - contentDescription = null, - ) - } - }, - ) -} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarAction.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarAction.kt deleted file mode 100644 index fce559650f..0000000000 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/TabBarAction.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.flipcash.app.core.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier - -/** - * A control published by [tab]'s home for the tab bar to draw beside the pill. - * - * The owning tab rides along because composition lifetime is the wrong clock for this. A tab press - * swaps the backstack, but the outgoing screen stays composed until the route transition finishes, - * so waiting for its dispose leaves the control on the bar for a beat after the indicator has - * already moved. [tab] changes on the press itself, so the bar can start shrinking in step with it. - */ -@Immutable -class TabBarActionEntry( - val tab: NavBarButton, - val content: @Composable (Modifier) -> Unit, -) - -/** - * Lets a tab home hang one control off the end of the tab bar's row, beside the pill. - * - * The bar is root chrome and knows nothing about any screen, so the screen publishes here and the - * bar renders what it finds. The control is handed a [Modifier] that already carries the pill's own - * size and frosted fill — the same chain, not a copy — so the two cannot drift apart, and the - * screen supplies only the click and the glyph. This is the same slot shape as the bar's `avatar`. - * - * Holding a `@Composable` lambda in state is deliberate: the control is composed in the *bar's* - * composition, which is what puts it in the bar's row and lets it share the bar's haze source. - */ -@Stable -class TabBarActionController { - var published: TabBarActionEntry? by mutableStateOf(null) - private set - - fun set(entry: TabBarActionEntry) { - published = entry - } - - /** - * Clears [entry] only if it is still the published one. Two screens overlap while a route - * transition runs, so the outgoing screen's dispose lands *after* the incoming screen has - * published; an unconditional clear would blank the incoming screen's control. - */ - fun clear(entry: TabBarActionEntry) { - if (published === entry) published = null - } - - /** The control to draw while [selectedTab] is the open tab, or null if that tab published none. */ - fun actionFor(selectedTab: NavBarButton): (@Composable (Modifier) -> Unit)? = - published?.takeIf { it.tab == selectedTab }?.content -} - -val LocalTabBarAction = staticCompositionLocalOf { TabBarActionController() } - -/** Publishes [content] into the tab bar's trailing slot for as long as [tab] is the open tab. */ -@Composable -fun TabBarAction(tab: NavBarButton, content: @Composable (Modifier) -> Unit) { - val controller = LocalTabBarAction.current - // The caller's lambda is a fresh instance on every recomposition. Publishing that directly would - // re-enter the DisposableEffect each time, so publish one stable entry and let it read through - // to the latest content. - val latest by rememberUpdatedState(content) - val entry = remember(tab) { TabBarActionEntry(tab) { modifier -> latest(modifier) } } - DisposableEffect(controller, entry) { - controller.set(entry) - onDispose { controller.clear(entry) } - } -} diff --git a/apps/flipcash/features/scanner/build.gradle.kts b/apps/flipcash/features/scanner/build.gradle.kts index 86bf4fe7ac..812002f78e 100644 --- a/apps/flipcash/features/scanner/build.gradle.kts +++ b/apps/flipcash/features/scanner/build.gradle.kts @@ -29,4 +29,7 @@ dependencies { 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 index ce21a3f4a4..e8bd0de186 100644 --- 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 @@ -4,63 +4,63 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag 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, seated on the tab bar's row beside the pill. + * 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. * - * [modifier] arrives from the bar's trailing slot already sized and already carrying the pill's - * frosted fill, so this draws only the glyph and the click. Nothing here decides how the control - * looks against the pill, which is the only way the two can be guaranteed to match. + * 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) } - Box( - modifier = modifier - .testTag("scanner_gallery_button") - .clickable { - launcher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - }, - contentAlignment = Alignment.Center, - ) { + 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( - // A step under the bar's own 30dp icon box because this glyph fills its viewport - // edge to edge, where the tab drawables carry padding inside theirs. Matching the box - // drew a visibly heavier icon than the tabs beside it; matching the ink reads level. - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), - // Outline, like the bar's unselected tabs: the filled twin is what a tab wears when it - // IS the open tab, and this is an action with no selected state to claim. It stays at - // full opacity where an unselected tab sits at half, which is the distinction that - // matters -- same weight, brighter, so it reads as available rather than as a tab. + // 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/bills/ScannableContainer.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt index 3536270980..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 @@ -3,18 +3,21 @@ 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.compose.collectAsStateWithLifecycle import com.flipcash.app.core.android.extensions.launchAppSettings -import com.flipcash.app.core.navigation.NavBarButton -import com.flipcash.app.core.navigation.TabBarAction import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.scanner.internal.GalleryScanButton @@ -23,17 +26,21 @@ 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( @@ -68,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 @@ -77,32 +90,34 @@ 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() + } } } } @@ -118,17 +133,25 @@ internal fun ScannableContainer( } val galleryEnabled by galleryFlag.collectAsStateWithLifecycle() - // Published to the tab bar rather than drawn here, so it sits on the bar's row beside the - // pill and takes width from it instead of floating over the preview. Nothing needs to be - // said about a presented bill: the bar hides itself whole while one is up, and this goes - // with it. + // Outside the permission `when` on purpose: a picked photo needs no camera, and someone + // who declined the camera is exactly who this is for. // - // Outside the permission `when` on purpose: a picked photo needs no camera, and someone who - // declined the camera is exactly who this is for. - if (galleryEnabled) { - TabBarAction(NavBarButton.Scanner) { modifier -> - GalleryScanButton(onImagePicked = onImagePicked, modifier = modifier) - } + // 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) + ) } } }