Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b9779cc
feat(kikcode): enumerate the still-image crop ladder
bmc08gt Sep 20, 2026
56e3eb4
feat(kikcode): convert ARGB pixels to a luminance plane
bmc08gt Sep 20, 2026
e9d721d
feat(scanner): search a still image for a Kik code
bmc08gt Sep 20, 2026
2a99550
feat(scanner): detect a QR code in a picked image
bmc08gt Sep 20, 2026
d2b47c5
fix(scanner): inject the static image analyzer through the constructor
bmc08gt Sep 20, 2026
fa0c779
refactor(scanner): name the scannable route allowlist
bmc08gt Sep 20, 2026
267b070
feat(scanner): add the scan-from-gallery feature flag
bmc08gt Sep 20, 2026
bbd250f
feat(scanner): add the gallery glyph
bmc08gt Sep 20, 2026
d4ed759
feat(scanner): scan a photo picked from the gallery
bmc08gt Sep 20, 2026
edc0381
feat(scanner): report gallery scan outcomes
bmc08gt Sep 20, 2026
4519eba
test(scanner): make the later-tier scene actually need a later tier
bmc08gt Sep 20, 2026
78fa050
fix(scanner): decode picked images off the main thread and under the …
bmc08gt Sep 20, 2026
4085bd4
fix(scanner): keep the new strings where the app keeps its strings
bmc08gt Sep 20, 2026
fd61997
fix(scanner): gate the expired-cash banner on the scan's origin
bmc08gt Sep 20, 2026
2828dec
fix(scanner): stop resubscribing to the gallery flag on every recompo…
bmc08gt Sep 20, 2026
1c3e3e3
fix(ui): restart KeepScreenOn when the brightness boost is toggled
bmc08gt Sep 20, 2026
9733484
chore(scanner): declare the vendor scanner dependency directly
bmc08gt Sep 20, 2026
52b257b
feat(nav): let a tab home hang a control off the tab bar's row
bmc08gt Sep 20, 2026
868f45a
fix(scanner): move the gallery button to the scanner's top right
bmc08gt Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Key, String>
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,7 @@
<string name="action_depositOtherCurrencies">Deposit Other Flipcash Currencies</string>

<string name="content_description_leaderboard">Learn more</string>
<string name="content_description_scanFromGallery">Scan a code from a photo</string>
<string name="prompt_title_learnAboutLeaderboard">Leaderboard Ranking</string>
<string name="prompt_description_learnAboutLeaderboard">Members must have a minimum balance of %1$s to be counted</string>

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Byte> = List(16) { it.toByte() }
val MINT = Mint(List(32) { it.toByte() })
val CHAT_ID = ChatId(ByteArray(16) { it.toByte() })
}
}
7 changes: 7 additions & 0 deletions apps/flipcash/features/scanner/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@ dependencies {
implementation(project(":libs:datetime"))
implementation(project(":libs:messaging"))
implementation(project(":libs:permissions:bindings"))
implementation(project(":libs:quickresponse"))
implementation(project(":libs:vibrator:bindings"))
implementation(project(":ui:biometrics"))
implementation(project(":ui:scanner"))
// Declared rather than inherited through `:ui:scanner`'s `api`: this module names
// `KikCodeScannerImpl`, `StaticImageAnalyzerImpl` and `KikCodeAnalyzer` directly.
implementation(project(":vendor:kik:scanner"))
implementation(libs.androidx.camerax.view)
implementation(libs.androidx.foundation.layout)
// `ui:components` keeps haze to itself, but `CircularIconButton` takes a `HazeState`, so a
// caller wanting the frosted fill has to be able to name the type.
implementation(libs.bundles.haze)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.flipcash.app.scanner.internal

import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.foundation.layout.size
import com.flipcash.features.scanner.R
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.CircularIconButton
import dev.chrisbanes.haze.HazeState

/**
* The Scan tab's gallery entry point, in the scanner's top-right corner.
*
* `PickVisualMedia` runs out of process, so tapping this never asks for a permission -- which is
* what lets the glyph be a plain static icon rather than a permission prompt with an icon on it.
* The app declares no `READ_MEDIA_IMAGES` and does not want to start.
*
* It sits on a frosted circle rather than bare on the preview because the backdrop here is whatever
* the camera happens to see. A white glyph alone has no contrast it can rely on -- point the phone
* at a bright wall and it disappears. [hazeState] must be a source that does NOT contain this
* button, or the blur samples itself; [com.flipcash.app.scanner.internal.bills.ScannableContainer]
* is what arranges that.
*/
@Composable
internal fun GalleryScanButton(
onImagePicked: (Uri) -> Unit,
modifier: Modifier = Modifier,
hazeState: HazeState? = null,
) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri -> uri?.let(onImagePicked) }

CircularIconButton(
modifier = modifier,
buttonSize = CodeTheme.dimens.staticGrid.x10,
imageSize = CodeTheme.dimens.staticGrid.x5,
hazeState = hazeState,
testTag = "scanner_gallery_button",
onClick = {
launcher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
) { size: Dp ->
Icon(
// Outline rather than filled: filled is the weight the nav bar gives a *selected* tab,
// and this is an action with no selected state to claim. Nothing on this screen is
// filled, so the outline is also what matches the surface it sits on.
imageVector = Icons.Outlined.PhotoLibrary,
contentDescription = stringResource(R.string.content_description_scanFromGallery),
tint = Color.White,
modifier = Modifier.size(size),
)
}
}
Loading
Loading