From 61995db5803cfe69882e39e0b99c8f0066872a78 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 10:54:43 -0400 Subject: [PATCH 1/4] feat(scanner): accept shared images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `image/*` SEND filter on MainActivity, so Flipcash appears in the share sheet for an image. Nothing handles the intent yet — that is the next commit. SEND_MULTIPLE is declared alongside SEND because some senders use it for a single image; the handler takes the first item and drops the rest. MainActivity is already `singleTask`, so a share into a running app arrives at `onNewIntent` rather than creating a second instance over the camera session. No launch-mode change was needed. --- .../flipcash/app/src/main/AndroidManifest.xml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml index 67662bd553..24c52b8a03 100644 --- a/apps/flipcash/app/src/main/AndroidManifest.xml +++ b/apps/flipcash/app/src/main/AndroidManifest.xml @@ -99,6 +99,28 @@ + + + + + + + + + + + + From ffa004e213a0b4b7187257f4827f5a0a1b3000df Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 11:00:38 -0400 Subject: [PATCH 2/4] feat(scanner): extract the shared image uri from an intent MainActivity's SEND filter (previous commit) puts Flipcash in the share sheet, but nothing yet reads the shared image back out. sharedImageUri is that read: it takes an image/* SEND or SEND_MULTIPLE and returns the Uri to scan, and null for anything else. Placed in apps/flipcash/core rather than MainActivity because the app module has no androidTest source set or Robolectric dependency to test a real Intent/Uri with; core already carries both. The function is public so MainActivity can call it once the channel wiring lands. It has to return null for ACTION_VIEW: MainActivity already routes tapped app-link Uris through the deeplink handler, and this must not compete with that path for the same intent. --- .../app/core/scanner/SharedImageIntent.kt | 33 ++++++++++ .../app/core/scanner/SharedImageIntentTest.kt | 60 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageIntent.kt create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/scanner/SharedImageIntentTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageIntent.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageIntent.kt new file mode 100644 index 0000000000..2606454529 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageIntent.kt @@ -0,0 +1,33 @@ +package com.flipcash.app.core.scanner + +import android.content.Intent +import android.net.Uri +import androidx.core.content.IntentCompat + +/** + * The image, if any, that an incoming intent shares into the app. + * + * `MainActivity` also owns `ACTION_VIEW`, arriving through dozens of `autoVerify` App Link + * filters — a tapped `flipcash.com`/`send.flipcash.com` link. This has to stay out of that path's + * way rather than compete with it, so it answers only for the two `SEND` actions the share-target + * intent filters register for, and returns null for everything else, `VIEW` included. + * + * `SEND_MULTIPLE` is accepted, not just `SEND`, because some senders (some gallery apps, some + * messaging clients) route even a single shared image through `SEND_MULTIPLE`. Dropping out of + * the share sheet for those senders is worse than taking the first image and ignoring the rest — + * scanning is a single-code action, so there is no second thing to do with the others anyway. + */ +fun sharedImageUri(intent: Intent): Uri? { + if (intent.type?.startsWith("image/") != true) return null + + return when (intent.action) { + Intent.ACTION_SEND -> + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + + Intent.ACTION_SEND_MULTIPLE -> + IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + ?.firstOrNull() + + else -> null + } +} diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/scanner/SharedImageIntentTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/scanner/SharedImageIntentTest.kt new file mode 100644 index 0000000000..51f7b87e04 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/scanner/SharedImageIntentTest.kt @@ -0,0 +1,60 @@ +package com.flipcash.app.core.scanner + +import android.content.Intent +import android.net.Uri +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * `MainActivity` already owns `ACTION_VIEW`: dozens of `autoVerify` App Link filters route a + * tapped link through the deeplink path, not this one. [sharedImageUri] has to stay out of that + * path's way, so the last two cases here aren't padding -- they pin that a text share and a view + * intent both come back null, leaving `VIEW` free for the deeplink handler to claim. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class SharedImageIntentTest { + + @Test + fun singleSendYieldsItsUri() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "image/jpeg" + putExtra(Intent.EXTRA_STREAM, IMAGE_URI) + } + assertEquals(IMAGE_URI, sharedImageUri(intent)) + } + + @Test + fun sendMultipleYieldsTheFirstUri() { + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "image/jpeg" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(IMAGE_URI, OTHER_IMAGE_URI)) + } + assertEquals(IMAGE_URI, sharedImageUri(intent)) + } + + @Test + fun aNonImageSendYieldsNothing() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, LINK) + } + assertNull(sharedImageUri(intent)) + } + + @Test + fun aViewIntentYieldsNothing() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LINK)) + assertNull(sharedImageUri(intent)) + } + + private companion object { + val IMAGE_URI: Uri = Uri.parse("content://media/external/images/media/42") + val OTHER_IMAGE_URI: Uri = Uri.parse("content://media/external/images/media/43") + const val LINK = "https://send.flipcash.com/c/#/e=abc" + } +} From 2a4704a2aef47ba58caa49a21bb3370279bbad50 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 11:13:48 -0400 Subject: [PATCH 3/4] feat(scanner): scan an image shared into the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An image shared to Flipcash now runs the same path a picked one does: the Kik ladder, then QR, then the `isScannable` allowlist, then the existing result handling. Reusing `onImagePicked` is deliberate — the allowlist is what keeps a crafted `/login` QR in a received image from reaching the login route, and a second entry point that scanned images its own way would be a second place for that guard to go missing. The payload can't travel the deeplink path: Rinku's `DeepLinkListener` only sees `intent.data`, and a share carries its image in `EXTRA_STREAM`. So `SharedImageChannel` holds the `Uri` from the moment the intent lands until the Scan tab composes, the way `EmailCodeChannel` already carries a verification code. It's a `StateFlow` rather than a one-shot emission because the tab subscribes late, after the navigation. The navigation lives in `App.kt` next to the deeplink effect, since `Scanner()` isn't composed while another tab is showing. `Scanner()` then clears the channel before calling the handler, so a failed scan isn't retried on every recomposition and a rotation mid-scan doesn't start a second search. A share that arrives while signed out is dropped rather than held across the sign-in — there's no Scan tab to route to, and an image somebody sent isn't worth resurrecting after an account switch. --- .../kotlin/com/flipcash/app/MainActivity.kt | 26 ++++++++++++++++ .../com/flipcash/app/internal/ui/App.kt | 28 +++++++++++++++++ .../core/scanner/LocalSharedImageChannel.kt | 7 +++++ .../app/core/scanner/SharedImageChannel.kt | 31 +++++++++++++++++++ .../flipcash/app/scanner/internal/Scanner.kt | 15 +++++++++ 5 files changed, 107 insertions(+) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/LocalSharedImageChannel.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageChannel.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt index 90151d7cf3..fafcab358e 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt @@ -24,6 +24,9 @@ import com.flipcash.app.contacts.LocalContactCoordinator import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.media.LocalMediaUrlResolver import com.flipcash.app.core.media.MediaUrlResolver +import com.flipcash.app.core.scanner.LocalSharedImageChannel +import com.flipcash.app.core.scanner.SharedImageChannel +import com.flipcash.app.core.scanner.sharedImageUri import com.flipcash.app.core.tipping.LocalTipCoordinator import com.flipcash.app.core.toast.LocalToastController import com.flipcash.app.core.toast.ToastController @@ -147,6 +150,9 @@ class MainActivity : FragmentActivity() { @Inject lateinit var mediaUrlResolver: MediaUrlResolver + @Inject + lateinit var sharedImageChannel: SharedImageChannel + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handleUncaughtException() @@ -158,6 +164,7 @@ class MainActivity : FragmentActivity() { lifecycleScope.launch(Dispatchers.Default) { phoneUtils.ensureLoaded() } applyBetaFlagLaunchOverrides() + handleSharedImage(intent) setContent { CompositionLocalProvider( @@ -183,6 +190,7 @@ class MainActivity : FragmentActivity() { LocalCoinbaseOnRampController provides coinbaseOnRampController, LocalTipCoordinator provides tippingCoordinator, LocalMediaUrlResolver provides mediaUrlResolver, + LocalSharedImageChannel provides sharedImageChannel, LocalUiTesting provides intent.getBooleanExtra(UI_TEST, false), ) { ProvidePermissionChecker(permissionChecker) { @@ -196,6 +204,24 @@ class MainActivity : FragmentActivity() { } } + // `singleTask` means a second share into an already-running task arrives here rather than + // through a fresh `onCreate`. `setIntent` first: `intent` is read elsewhere on this activity + // (`applyBetaFlagLaunchOverrides`, `handleUncaughtException`, the `LocalUiTesting` provider + // above), so the activity's current intent has to be the new one before anything downstream + // looks at it again. + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleSharedImage(intent) + } + + // The `Uri` a share grants read access to is scoped to the life of the task that received it, + // so the read has to happen inside this task — which it does, since the scanner reads it + // after navigation in this same task rather than handing it off anywhere else. + private fun handleSharedImage(intent: Intent) { + sharedImageUri(intent)?.let(sharedImageChannel::offer) + } + /** * Test-only: enable beta flags passed as a launch argument, so flag-gated features * can be exercised in UI tests without toggling them in the Labs UI. Mirrors iOS's diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index 2fe637eb96..a7d618a476 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -46,6 +46,7 @@ import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.core.scanner.LocalSharedImageChannel import com.flipcash.app.core.ui.NavigationBar import com.flipcash.app.core.verification.email.LocalEmailCodeChannel import com.flipcash.app.featureflags.FeatureFlag @@ -231,6 +232,7 @@ internal fun App( } val emailCodeChannel = LocalEmailCodeChannel.current + val sharedImageChannel = LocalSharedImageChannel.current val currentRoute = codeNavigator.currentRouteKey val keyboard = rememberKeyboardController() LaunchedEffect(deepLink, currentRoute) { @@ -310,6 +312,32 @@ internal fun App( deepLink = null } + // Mirrors the deeplink effect above: `Scanner()` isn't composed + // while another tab is showing, so the navigation to it can't live + // inside the scanner itself. + val pendingSharedImage by sharedImageChannel.pending.collectAsStateWithLifecycle() + LaunchedEffect(pendingSharedImage, currentRoute) { + val uri = pendingSharedImage ?: return@LaunchedEffect + + if (currentRoute is AppRoute.Loading) { + // Cold start — wait until navigation leaves Loading + // before routing to the Scan tab. + return@LaunchedEffect + } + + if (userState.authState == AuthState.LoggedOut) { + // No Scan tab to route to, and a shared image isn't worth + // carrying across a sign-in. + sharedImageChannel.clear() + return@LaunchedEffect + } + + // Not cleared here on the success path — `Scanner()` consumes + // it once composed; clearing here would drop it before the tab + // composes. + codeNavigator.navigateAll(listOf(AppRoute.Tabs.Scanner)) + } + LaunchedEffect(userState.authState) { if (userState.authState == AuthState.LoggedOut) { val current = codeNavigator.currentRouteKey diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/LocalSharedImageChannel.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/LocalSharedImageChannel.kt new file mode 100644 index 0000000000..c70e560101 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/LocalSharedImageChannel.kt @@ -0,0 +1,7 @@ +package com.flipcash.app.core.scanner + +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalSharedImageChannel = staticCompositionLocalOf { + error("No SharedImageChannel provided") +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageChannel.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageChannel.kt new file mode 100644 index 0000000000..335520651d --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageChannel.kt @@ -0,0 +1,31 @@ +package com.flipcash.app.core.scanner + +import android.net.Uri +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Carries an image shared into the app from `MainActivity`'s intent to the Scan tab. + * + * A `StateFlow`, not a `SharedFlow`: the image has to survive from the moment the intent lands + * (`onCreate`/`onNewIntent`, before any UI is composed) until the Scan tab is actually composed, + * which may be after a navigation and a recomposition or two — a one-shot emission could be + * missed entirely. Holding the value, not just emitting it, is what makes a late subscriber + * (the Scan tab, composed after the app has already routed there) still see it. + */ +@Singleton +class SharedImageChannel @Inject constructor() { + private val _pending = MutableStateFlow(null) + val pending: StateFlow = _pending.asStateFlow() + + fun offer(uri: Uri) { + _pending.value = uri + } + + fun clear() { + _pending.value = null + } +} 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 fafb3b6806..3f8160eb1c 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 @@ -28,6 +28,7 @@ import com.flipcash.app.core.AppRoute.Token.* import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.app.core.scanner.LocalSharedImageChannel import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.router.LocalRouter @@ -208,6 +209,20 @@ internal fun Scanner() { Unit } + // A share into a running app, or one still pending from a cold start, lands here — after + // `onImagePicked` above so it can be referenced. `Scanner()` isn't composed while another tab + // is showing, so this is the earliest point a shared image can be picked up; `App.kt` only + // gets it as far as navigating to this tab. + val sharedImageChannel = LocalSharedImageChannel.current + val pendingSharedImage by sharedImageChannel.pending.collectAsStateWithLifecycle() + LaunchedEffect(pendingSharedImage) { + val uri = pendingSharedImage ?: return@LaunchedEffect + // Cleared before the call, not after: a failed scan must not be retried on every + // recomposition, and a rotation mid-scan must not start a second search. + sharedImageChannel.clear() + onImagePicked(uri) + } + // 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. // Covers both scan shapes (QR tip link and OpenCode tip payload); they share the guard in From c49e4f5c0b0cfeebc4ca91f3c094b759a95a2823 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 12:11:45 -0400 Subject: [PATCH 4/4] fix(scanner): close the deeplink route a shared image opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The share filters are the only ones on `MainActivity` that don't pin a scheme and host. A filter naming just a mime type accepts `content:` and `file:`, so a sender can hang an arbitrary data `Uri` off an otherwise ordinary image share. `Rinku` is installed with no `DeepLinkFilter`, so the listener reads `intent.data` and routes whatever is there, and `isLogin()` matches on the first path segment alone — no host or scheme check. That reaches tapped-link handling, which is allowed to open `/login`; the scan path refuses it via `DeeplinkType.isScannable`. So the filter added for scanning also handed a sender the one route that bypasses the guard the feature exists to enforce. `consumeSharedImage` now clears `data` and removes `EXTRA_STREAM` on any `SEND`/`SEND_MULTIPLE`, and `MainActivity` calls it before `super.onNewIntent`, which is where the listener runs. The strip can't wait until an image is found: a sender that wants the deeplink rather than the scan simply omits the image. Removing `EXTRA_STREAM` also stops `singleTask` replaying the same share on every restore from recents after a process death. Three narrower fixes alongside it: - The auth gate only dropped a share on `LoggedOut`, so one arriving during onboarding or on the restriction screen navigated away from it. `navigateAll` clears the back stack, leaving nothing to return to. It now requires `Ready` and holds — rather than decides — while auth is still resolving. - The scan ran under the biometric app lock. The overlay draws over the scanner rather than replacing it, so the effect beneath still ran, and a shared cash link would sign and submit a grab on a locked app. Held until `passed`. - The effect re-runs on every route change, so it navigated a second time once already on the Scan tab. Tests cover the strip, its unconditional application, the replay, and that a `VIEW` intent is left untouched so App Links keep working. --- .../kotlin/com/flipcash/app/MainActivity.kt | 20 +++++--- .../com/flipcash/app/internal/ui/App.kt | 32 +++++++++--- .../app/core/scanner/SharedImageIntent.kt | 33 ++++++++++++ .../app/core/scanner/SharedImageIntentTest.kt | 51 +++++++++++++++++++ .../flipcash/app/scanner/internal/Scanner.kt | 9 +++- 5 files changed, 130 insertions(+), 15 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt index fafcab358e..34f2f6b426 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt @@ -26,7 +26,7 @@ import com.flipcash.app.core.media.LocalMediaUrlResolver import com.flipcash.app.core.media.MediaUrlResolver import com.flipcash.app.core.scanner.LocalSharedImageChannel import com.flipcash.app.core.scanner.SharedImageChannel -import com.flipcash.app.core.scanner.sharedImageUri +import com.flipcash.app.core.scanner.consumeSharedImage import com.flipcash.app.core.tipping.LocalTipCoordinator import com.flipcash.app.core.toast.LocalToastController import com.flipcash.app.core.toast.ToastController @@ -205,21 +205,27 @@ class MainActivity : FragmentActivity() { } // `singleTask` means a second share into an already-running task arrives here rather than - // through a fresh `onCreate`. `setIntent` first: `intent` is read elsewhere on this activity - // (`applyBetaFlagLaunchOverrides`, `handleUncaughtException`, the `LocalUiTesting` provider - // above), so the activity's current intent has to be the new one before anything downstream - // looks at it again. + // through a fresh `onCreate`. + // + // The share is taken before `super`, not after: the deeplink listener runs inside + // `super.onNewIntent` and reads `intent.data`, and stripping that is half of what + // `handleSharedImage` is for. `setIntent` then stores the stripped intent, which matters + // because `intent` is read elsewhere on this activity (`applyBetaFlagLaunchOverrides`, + // `handleUncaughtException`, the `LocalUiTesting` provider above). override fun onNewIntent(intent: Intent) { + handleSharedImage(intent) super.onNewIntent(intent) setIntent(intent) - handleSharedImage(intent) } // The `Uri` a share grants read access to is scoped to the life of the task that received it, // so the read has to happen inside this task — which it does, since the scanner reads it // after navigation in this same task rather than handing it off anywhere else. + // + // `consumeSharedImage` also strips the intent it reads; see its docs for why that has to + // happen before the deeplink listener sees it. private fun handleSharedImage(intent: Intent) { - sharedImageUri(intent)?.let(sharedImageChannel::offer) + consumeSharedImage(intent)?.let(sharedImageChannel::offer) } /** diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index a7d618a476..47c48262f1 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -317,17 +317,35 @@ internal fun App( // inside the scanner itself. val pendingSharedImage by sharedImageChannel.pending.collectAsStateWithLifecycle() LaunchedEffect(pendingSharedImage, currentRoute) { - val uri = pendingSharedImage ?: return@LaunchedEffect + if (pendingSharedImage == null) return@LaunchedEffect - if (currentRoute is AppRoute.Loading) { - // Cold start — wait until navigation leaves Loading - // before routing to the Scan tab. + val authState = userState.authState + + // Cold start, or a session still resolving — hold the image + // until navigation and auth have both settled rather than + // deciding on a state that is about to change. + if (currentRoute is AppRoute.Loading || + authState == AuthState.Unknown || + authState == AuthState.Authenticating + ) { return@LaunchedEffect } - if (userState.authState == AuthState.LoggedOut) { - // No Scan tab to route to, and a shared image isn't worth - // carrying across a sign-in. + // Already there: `Scanner()` is composed and takes it from + // here. Returning also keeps this effect, which re-runs on + // every route change, from navigating a second time. + if (currentRoute is AppRoute.Tabs.Scanner) { + return@LaunchedEffect + } + + // Anything short of a ready session on an ordinary route has + // no Scan tab to reach, and `navigateAll` clears the back + // stack: routing a user out of onboarding or off the + // restriction screen strands them with nothing to come back + // to. A shared image is not worth that, so drop it. + if (authState != AuthState.Ready || + currentRoute is AppRoute.Main.AppRestricted + ) { sharedImageChannel.clear() return@LaunchedEffect } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageIntent.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageIntent.kt index 2606454529..6de73de9ff 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageIntent.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageIntent.kt @@ -31,3 +31,36 @@ fun sharedImageUri(intent: Intent): Uri? { else -> null } } + +/** + * Takes the shared image out of [intent], leaving nothing behind that the rest of the app could + * act on. + * + * Returns what [sharedImageUri] returns, and additionally strips two things from [intent] itself. + * Both strips run for any share, including one carrying no image at all, because the sender + * chooses what the intent holds: + * + * - **`data`.** The share filters are the only ones on `MainActivity` that don't pin a scheme and + * host; a filter naming just a mime type accepts `content:` and `file:`. So a sender can hang an + * arbitrary `Uri` off an otherwise ordinary image share, and the deeplink listener reads + * `intent.data` and routes it. That route is tapped-link handling, which is allowed to open + * `/login` — the thing the scan path refuses via `DeeplinkType.isScannable`. Clearing `data` + * stops a share reaching it at all. + * - **`EXTRA_STREAM`.** `MainActivity` is `singleTask`, so this intent becomes the task's own and + * is redelivered to `onCreate` on every restore from recents after a process death. Without the + * strip, the same image is scanned again each time, long after the user shared it. + * + * Apply this before anything else reads the intent — in particular before `super.onNewIntent`, + * which is where the deeplink listener runs. + */ +fun consumeSharedImage(intent: Intent): Uri? { + if (intent.action != Intent.ACTION_SEND && intent.action != Intent.ACTION_SEND_MULTIPLE) { + return null + } + + val uri = sharedImageUri(intent) + // Not `data = null`: that clears the type too, and the type is what `sharedImageUri` keys off. + intent.setDataAndType(null, intent.type) + intent.removeExtra(Intent.EXTRA_STREAM) + return uri +} diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/scanner/SharedImageIntentTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/scanner/SharedImageIntentTest.kt index 51f7b87e04..6a740d73f3 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/scanner/SharedImageIntentTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/scanner/SharedImageIntentTest.kt @@ -52,9 +52,60 @@ class SharedImageIntentTest { assertNull(sharedImageUri(intent)) } + @Test + fun consumingAShareClearsTheDataUriItCameWith() { + // The share filters are the only ones on `MainActivity` that don't pin a scheme and host, + // so a sender can attach any `Uri` it likes alongside the image. Left in place it reaches + // the deeplink listener, which -- unlike the scan path -- will open `/login`. + val intent = Intent(Intent.ACTION_SEND).apply { + setDataAndType(Uri.parse(HOSTILE_LOGIN_LINK), "image/jpeg") + putExtra(Intent.EXTRA_STREAM, IMAGE_URI) + } + + assertEquals(IMAGE_URI, consumeSharedImage(intent)) + assertNull(intent.data) + } + + @Test + fun consumingAShareCarryingNoImageStillClearsItsDataUri() { + // The strip can't be conditional on finding an image: a sender that wants the deeplink, + // not the scan, simply omits `EXTRA_STREAM`. + val intent = Intent(Intent.ACTION_SEND).apply { + setDataAndType(Uri.parse(HOSTILE_LOGIN_LINK), "image/jpeg") + } + + assertNull(consumeSharedImage(intent)) + assertNull(intent.data) + } + + @Test + fun aConsumedShareYieldsNothingASecondTime() { + // `MainActivity` is `singleTask`, so this intent stays the task's own and is redelivered + // to `onCreate` on every restore from recents after a process death. + val intent = Intent(Intent.ACTION_SEND).apply { + type = "image/jpeg" + putExtra(Intent.EXTRA_STREAM, IMAGE_URI) + } + + assertEquals(IMAGE_URI, consumeSharedImage(intent)) + assertNull(consumeSharedImage(intent)) + } + + @Test + fun consumingLeavesAViewIntentAlone() { + // `VIEW` belongs to the App Link filters. Stripping it here would break every tapped + // `flipcash.com` link in the app. + val link = Uri.parse(LINK) + val intent = Intent(Intent.ACTION_VIEW, link) + + assertNull(consumeSharedImage(intent)) + assertEquals(link, intent.data) + } + private companion object { val IMAGE_URI: Uri = Uri.parse("content://media/external/images/media/42") val OTHER_IMAGE_URI: Uri = Uri.parse("content://media/external/images/media/43") const val LINK = "https://send.flipcash.com/c/#/e=abc" + const val HOSTILE_LOGIN_LINK = "content://evil.example/login/#/e=attacker" } } 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 3f8160eb1c..2a2ab9d394 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 @@ -215,8 +215,15 @@ internal fun Scanner() { // gets it as far as navigating to this tab. val sharedImageChannel = LocalSharedImageChannel.current val pendingSharedImage by sharedImageChannel.pending.collectAsStateWithLifecycle() - LaunchedEffect(pendingSharedImage) { + LaunchedEffect(pendingSharedImage, biometricsState) { val uri = pendingSharedImage ?: return@LaunchedEffect + // Held rather than dropped while the app lock is up. The other two entry points are + // already covered — the camera checks `passed` below, and the gallery button sits under + // the blocking overlay — but this one arrives from outside the app, and the overlay draws + // over this entry rather than replacing it, so effects underneath still run. Without the + // check a share would run its side effects, up to signing and submitting a grab, on a + // locked app. + if (!biometricsState.passed) return@LaunchedEffect // Cleared before the call, not after: a failed scan must not be retried on every // recomposition, and a rotation mid-scan must not start a second search. sharedImageChannel.clear()