Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/flipcash/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:common-ui"))
implementation(project(":apps:flipcash:shared:notifications"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:chat-ui"))
implementation(project(":apps:flipcash:shared:onramp:coinbase"))
implementation(project(":apps:flipcash:shared:onramp:deeplinks"))
implementation(libs.phantom.connect) {
Expand Down
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>

<!--
Share target: text and links, and one or many images, sent from another app's share
sheet. MainActivity reads the intent (onCreate / onNewIntent), copies the images
out of the sender's grant, and opens a chat picker; nothing is sent from here.

Direct share (a conversation row in the sheet) is a later change: it needs a
sharing-shortcuts target (`android.service.chooser.chooser_target_service` /
`android.app.shortcuts` meta-data) and pushes shortcuts whose id arrives as
EXTRA_SHORTCUT_ID, which chatIdFromShortcutId maps back to a chat.
-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<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
33 changes: 33 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 @@ -40,6 +40,9 @@ import com.flipcash.app.internal.ui.App
import com.flipcash.app.phone.LocalPhoneUtils
import com.flipcash.app.phone.PhoneUtils
import com.flipcash.app.router.LocalRouter
import com.flipcash.shared.chat.share.LocalPendingChatShares
import com.flipcash.shared.chat.share.PendingChatShares
import com.flipcash.shared.chat.ui.media.receiveChatShare
import com.flipcash.app.router.Router
import com.flipcash.app.session.LocalSessionController
import com.flipcash.app.session.SessionController
Expand Down Expand Up @@ -152,6 +155,9 @@ class MainActivity : FragmentActivity() {
@Inject
lateinit var trustedWebsites: TrustedWebsites

@Inject
lateinit var pendingChatShares: PendingChatShares

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleUncaughtException()
Expand All @@ -164,6 +170,11 @@ class MainActivity : FragmentActivity() {

applyBetaFlagLaunchOverrides()

// Only a fresh launch carries a share nobody has read yet. A recreated activity (rotation,
// process restore) gets the same intent back with a saved state, and reading it again would
// stage the share a second time.
if (savedInstanceState == null) receiveShare(intent)

setContent {
CompositionLocalProvider(
LocalResources provides resources,
Expand All @@ -189,6 +200,7 @@ class MainActivity : FragmentActivity() {
LocalCoinbaseOnRampController provides coinbaseOnRampController,
LocalTipCoordinator provides tippingCoordinator,
LocalMediaUrlResolver provides mediaUrlResolver,
LocalPendingChatShares provides pendingChatShares,
LocalUiTesting provides intent.getBooleanExtra(UI_TEST, false),
) {
ProvidePermissionChecker(permissionChecker) {
Expand All @@ -202,6 +214,27 @@ class MainActivity : FragmentActivity() {
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
receiveShare(intent)
}

/**
* Takes images and text shared from another app (ACTION_SEND / ACTION_SEND_MULTIPLE) into
* [pendingChatShares]; the app then shows the chat picker. Images are copied before anything
* else, while this activity still holds the sender's read grant. Other intents are ignored.
*/
private fun receiveShare(intent: Intent) {
val action = intent.action
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
// Before the copy starts, so the launch route is not chosen while the share is in flight.
pendingChatShares.expectIncoming()
lifecycleScope.launch {
receiveChatShare(applicationContext, intent, pendingChatShares)
}
}

/**
* 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 @@ -67,7 +67,9 @@ import com.flipcash.app.router.LocalRouter
import com.flipcash.app.session.LocalSessionController
import com.flipcash.app.theme.FlipcashTheme
import com.flipcash.features.shareapp.R
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.services.user.AuthState
import com.flipcash.shared.chat.share.LocalPendingChatShares
import com.getcode.animation.LocalSharedTransitionScope
import com.getcode.utils.trace
import com.getcode.libs.biometrics.BiometricsError
Expand Down Expand Up @@ -323,6 +325,26 @@ internal fun App(
deepLink = null
}

// A share from another app: once the app is past its cold-start Loading
// route and signed in, open the chat picker, which selects the chat the
// share already names (where direct share lands).
// Anyone who isn't signed in just gets the app: the share is dropped.
val pendingShares = LocalPendingChatShares.current
val pendingShare = pendingShares?.pending?.collectAsStateWithLifecycle()?.value
LaunchedEffect(pendingShare, currentRoute, userState.authState) {
val shares = pendingShares ?: return@LaunchedEffect
if (pendingShare == null || currentRoute is AppRoute.Loading) return@LaunchedEffect
when (userState.authState) {
AuthState.Ready -> {
shares.beginPresenting() ?: return@LaunchedEffect
keyboard.hide()
codeNavigator.navigate(AppRoute.Messaging.SharePicker)
}
is AuthState.Onboarding, AuthState.LoggedOut -> shares.discard()
else -> Unit
}
}

LaunchedEffect(userState.authState) {
if (userState.authState == AuthState.LoggedOut) {
val current = codeNavigator.currentRouteKey
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import com.flipcash.app.messenger.ChatPhotoViewerScreen
import com.flipcash.app.messenger.E2eeLearnMoreScreen
import com.flipcash.app.messenger.MuteChatScreen
import com.flipcash.app.messenger.ProfileScreen
import com.flipcash.app.messenger.SharePickerScreen
import com.flipcash.app.messenger.ShareProfileToChatsScreen
import com.flipcash.app.messenger.ReportFlowScreen
import com.flipcash.app.discovery.TokenDiscoveryScreen
Expand Down Expand Up @@ -144,6 +145,7 @@ fun appEntryProvider(
annotatedEntry<AppRoute.Messaging.Report> { key ->
ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry<AppRoute.Messaging.SharePicker> { SharePickerScreen() }
annotatedEntry<AppRoute.Messaging.ShareProfileToChats> { key -> ShareProfileToChatsScreen(key) }
annotatedEntry<AppRoute.Messaging.MuteChat> { key -> MuteChatScreen(key.chatId, key.chatType) }
annotatedEntry<AppRoute.Messaging.PhotoViewer> { key -> ChatPhotoViewerScreen(key.chatId, key.messageId) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import com.flipcash.app.core.extensions.navigateAll
import com.flipcash.app.core.extensions.resolveBackStack
import com.flipcash.app.router.LocalRouter
import com.flipcash.app.router.Router
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.services.user.AuthState
import com.flipcash.shared.chat.share.LocalPendingChatShares
import com.flipcash.shared.chat.share.PendingChatShares
import com.getcode.navigation.core.CodeNavigator
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.theme.CodeTheme
Expand All @@ -42,9 +45,11 @@ import com.getcode.utils.trace
import dev.theolm.rinku.DeepLink
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds

@Composable
Expand All @@ -57,6 +62,7 @@ internal fun MainRoot(
var showLoading by remember { mutableStateOf(false) }
val router = LocalRouter.current!!
var showLogo by remember { mutableStateOf(true) }
val pendingShares = LocalPendingChatShares.current
Box(
modifier = Modifier
.fillMaxSize()
Expand Down Expand Up @@ -104,10 +110,16 @@ internal fun MainRoot(
"state" to state
}
)
// A launch by a share opens on the picker, so the share has to be known before the
// first real route is chosen; waiting here keeps the Loading screen up meanwhile.
val shareRoute = if (state == AuthState.Ready) {
pendingShares?.let { claimShareRoute(it) }
} else null
val launch = buildNavGraphForLaunch(
state = state,
router = router,
deepLink = deepLink
deepLink = deepLink,
shareRoute = shareRoute,
)

when (state) {
Expand Down Expand Up @@ -183,10 +195,29 @@ private fun List<NavKey>.startsWith(prefix: List<NavKey>): Boolean {
return prefix.indices.all { i -> this[i]::class == prefix[i]::class }
}

/**
* The screen a share from another app opens on, once it has finished arriving, or null when this
* launch carries no share. Claims the share, so the root's own share handling doesn't push the
* same screen a second time.
*/
private suspend fun claimShareRoute(shares: PendingChatShares): AppRoute? {
// Bounded: a copy that never finishes must not leave the app on its splash screen.
withTimeoutOrNull(ShareArrivalTimeout) { shares.incoming.first { !it } }
shares.beginPresenting() ?: return null
return AppRoute.Messaging.SharePicker
}

private val ShareArrivalTimeout = 3.seconds

/**
* @param shareRoute the screen a share from another app opens on, which goes straight on top of the
* start screen so it is the first one shown. Wins over a deeplink: the launch is the share.
*/
internal fun buildNavGraphForLaunch(
state: AuthState,
router: Router,
deepLink: () -> DeepLink?,
shareRoute: AppRoute? = null,
): LaunchNavGraph? {
return when (state) {
is AuthState.Onboarding -> when (state.resumePoint) {
Expand Down Expand Up @@ -218,7 +249,9 @@ internal fun buildNavGraphForLaunch(
AuthState.Ready -> {
val home = homeRoute
val link = deepLink()
if (link != null) {
if (shareRoute != null) {
LaunchNavGraph(listOf(launchRoute, shareRoute))
} else if (link != null) {
when (val action = router.dispatch(link)) {
is DeeplinkAction.Navigate -> LaunchNavGraph(
baseRoutes = listOf(home),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ class BuildNavGraphForLaunchTest {

// -- Ready --

@Test
fun `a share launch puts the picker straight on top of the Chats tab`() {
val result = buildNavGraphForLaunch(
state = AuthState.Ready,
router = FakeRouter(DeeplinkAction.None),
deepLink = { null },
shareRoute = AppRoute.Messaging.SharePicker,
)!!
assertEquals(listOf(AppRoute.Tabs.Chats, AppRoute.Messaging.SharePicker), result.baseRoutes)
assertTrue(result.deeplinkRoutes.isEmpty())
}

@Test
fun `logged in without deeplink opens on the Chats tab`() {
val result = build(AuthState.Ready)!!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,16 @@ sealed interface AppRoute : NavKey, Parcelable {
com.getcode.navigation.Sheet,
com.getcode.navigation.WrapContentSheet

/**
* Where a share from another app picks the conversation it goes to. Opened by the app
* itself when a share is waiting in `PendingChatShares`, and sends the share from there
* to the chats picked. Carries nothing: what was shared is held there, not on a route a back stack
* would save and restore.
*/
@Serializable
@Parcelize
data object SharePicker : Messaging

/**
* A person's profile link, shared by the system share sheet, copied, or sent into recent
* chats. Opened from the viewer's own profile and from someone else's, so it carries what
Expand Down
15 changes: 14 additions & 1 deletion apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1198,7 +1198,20 @@
<string name="title_invitePeople">Invite People</string>
<string name="action_shareInviteLink">Share</string>
<string name="label_recentChats">Recent Chats</string>
<string name="hint_addAMessage">Add a message</string>
<string name="title_shareToChat">Send to</string>
<string name="label_chats">Chats</string>
<string name="label_suggested">Suggested</string>
<string name="label_shareImageLimit">Only the first %1$d images are sent.</string>
<string name="label_shareSendingProgress">Sending %1$d of %2$d</string>
<string name="label_shareFailure">%1$s: %2$s</string>
<string name="error_shareSendFailed">Couldn\'t send. Check your connection and try again.</string>
<string name="error_shareImageRejected">This image can\'t be sent.</string>
<string name="error_shareImageUnprepared">This image couldn\'t be prepared for sending.</string>
<string name="label_shareNoMatches">No chats match \u201C%1$s\u201D</string>
<string name="title_shareNoChats">No chats yet</string>
<string name="subtitle_shareNoChats">Start a chat in Flipcash, then share again.</string>
<string name="label_shareAttachedCount">%1$d attached</string>
<string name="hint_addAMessage">Add a message\u2026</string>
<string name="title_tipIntro">Receive Tips From Everyone</string>
<string name="subtitle_tipIntro">Add your name to receive tips</string>
<string name="action_startReceivingTips">Start Receiving Tips</string>
Expand Down
Loading
Loading