Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions apps/flipcash/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,28 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!--
Image share target. An image shared to Flipcash is scanned by the same path a
picked one takes — see `Scanner.onImagePicked`, which runs the Kik ladder, then
QR, then `DeeplinkType.isScannable`. The allowlist matters more here than it does
for the gallery: a shared image is one somebody else put in front of the user,
which is the shape of a phishing attempt against the login route.

SEND_MULTIPLE is declared because some senders use it for a single image, and
being absent from the sheet for those is worse than taking the first item of a
multi-select and dropping the rest.
-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>

<!-- app.flipcash.com App Links -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
Expand Down
32 changes: 32 additions & 0 deletions apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -158,6 +164,7 @@ class MainActivity : FragmentActivity() {
lifecycleScope.launch(Dispatchers.Default) { phoneUtils.ensureLoaded() }

applyBetaFlagLaunchOverrides()
handleSharedImage(intent)

setContent {
CompositionLocalProvider(
Expand All @@ -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) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.flipcash.app.core.scanner

import androidx.compose.runtime.staticCompositionLocalOf

val LocalSharedImageChannel = staticCompositionLocalOf<SharedImageChannel> {
error("No SharedImageChannel provided")
}
Original file line number Diff line number Diff line change
@@ -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<Uri?>(null)
val pending: StateFlow<Uri?> = _pending.asStateFlow()

fun offer(uri: Uri) {
_pending.value = uri
}

fun clear() {
_pending.value = null
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading