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 @@
+
+
+
+
+
+
+
+
+
+
+
+
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..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
@@ -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.consumeSharedImage
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,30 @@ class MainActivity : FragmentActivity() {
}
}
+ // `singleTask` means a second share into an already-running task arrives here rather than
+ // 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)
+ }
+
+ // 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) {
+ consumeSharedImage(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..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
@@ -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,50 @@ 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) {
+ if (pendingSharedImage == null) return@LaunchedEffect
+
+ 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
+ }
+
+ // 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
+ }
+
+ // 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/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..6de73de9ff
--- /dev/null
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/scanner/SharedImageIntent.kt
@@ -0,0 +1,66 @@
+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
+ }
+}
+
+/**
+ * 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
new file mode 100644
index 0000000000..6a740d73f3
--- /dev/null
+++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/scanner/SharedImageIntentTest.kt
@@ -0,0 +1,111 @@
+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))
+ }
+
+ @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 fafb3b6806..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
@@ -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,27 @@ 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, 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()
+ 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