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