diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts
index fc4b0d59f..deb645406 100644
--- a/apps/flipcash/app/build.gradle.kts
+++ b/apps/flipcash/app/build.gradle.kts
@@ -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) {
diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml
index 4b69d97ab..2bd62eb7a 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 ceabcf1c7..674c1b5b5 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
@@ -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
@@ -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()
@@ -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,
@@ -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) {
@@ -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
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 cff981eea..c8c771f0f 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
@@ -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
@@ -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
diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt
index 6cd78e242..c0bb84fd0 100644
--- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt
+++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt
@@ -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
@@ -144,6 +145,7 @@ fun appEntryProvider(
annotatedEntry { key ->
ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
+ annotatedEntry { SharePickerScreen() }
annotatedEntry { key -> ShareProfileToChatsScreen(key) }
annotatedEntry { key -> MuteChatScreen(key.chatId, key.chatType) }
annotatedEntry { key -> ChatPhotoViewerScreen(key.chatId, key.messageId) }
diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt
index 3eec0c76d..b36b515b4 100644
--- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt
+++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt
@@ -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
@@ -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
@@ -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()
@@ -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) {
@@ -183,10 +195,29 @@ private fun List.startsWith(prefix: List): 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) {
@@ -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),
diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt
index dc056923b..522c58d98 100644
--- a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt
+++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt
@@ -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)!!
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt
index 97d284c75..4c351dcc6 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt
@@ -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
diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index e6b013469..667da3672 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -1198,7 +1198,20 @@
Invite People
Share
Recent Chats
- Add a message
+ Send to
+ Chats
+ Suggested
+ Only the first %1$d images are sent.
+ Sending %1$d of %2$d
+ %1$s: %2$s
+ Couldn\'t send. Check your connection and try again.
+ This image can\'t be sent.
+ This image couldn\'t be prepared for sending.
+ No chats match \u201C%1$s\u201D
+ No chats yet
+ Start a chat in Flipcash, then share again.
+ %1$d attached
+ Add a message\u2026
Receive Tips From Everyone
Add your name to receive tips
Start Receiving Tips
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/SharePickerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/SharePickerScreen.kt
new file mode 100644
index 000000000..976fc3319
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/SharePickerScreen.kt
@@ -0,0 +1,412 @@
+package com.flipcash.app.messenger
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material.Text
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import com.flipcash.shared.chat.share.MAX_SHARE_IMAGES
+import com.flipcash.shared.chat.share.ShareChatStatus
+import com.flipcash.shared.chat.share.ShareFailure
+import com.flipcash.shared.chat.share.ShareSending
+import com.flipcash.shared.chat.share.ShareSendProgress
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil3.compose.AsyncImage
+import com.flipcash.app.core.AppRoute
+import com.flipcash.app.core.chat.ChatIdentifier
+import com.flipcash.app.messenger.internal.SharePickerViewModel
+import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.shared.chat.ui.ConversationReference
+import com.flipcash.shared.common.ui.ContactAvatar
+import com.getcode.navigation.core.LocalCodeNavigator
+import com.getcode.theme.CodeTheme
+import com.getcode.theme.White50
+import com.getcode.ui.components.AppBarDefaults
+import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.components.SearchInput
+import com.getcode.ui.components.chat.ChatInput
+import com.getcode.ui.components.chat.ChatInputSubmit
+import com.getcode.ui.theme.CodeCircularProgressIndicator
+import com.getcode.ui.utils.rememberKeyboardController
+
+/**
+ * Where a share from another app picks its conversations and is sent, behind
+ * `AppRoute.Messaging.SharePicker`. The iOS share extension's "Send to" screen: a title with a
+ * close button, a search field, the chats grouped under "Suggested" and "Recent chats", and, once
+ * one is picked, a bar with the shared images, a message field, who it goes to and Send (Retry
+ * after a failure). Up to five chats can be picked; it closes when every one has the share.
+ *
+ * Differs from iOS where Android does: the search field and bar use the app's own components, the
+ * Send button sits inside the message field as it does in the chat composer, and the selection
+ * indicator is the app's check mark. Failure lines, the image limit notice and "Sending X of Y"
+ * are as on iOS.
+ */
+@Composable
+fun SharePickerScreen() {
+ val viewModel = hiltViewModel()
+ val allChats by viewModel.chats.collectAsStateWithLifecycle()
+ val chats by viewModel.visibleChats.collectAsStateWithLifecycle()
+ val selected by viewModel.selected.collectAsStateWithLifecycle()
+ val progress by viewModel.progress.collectAsStateWithLifecycle()
+ val sending by viewModel.sending.collectAsStateWithLifecycle()
+ val done by viewModel.done.collectAsStateWithLifecycle()
+ val limitHits by viewModel.limitHits.collectAsStateWithLifecycle()
+ val navigator = LocalCodeNavigator.current
+ val haptics = LocalHapticFeedback.current
+ val query = viewModel.queryState.text.toString().trim()
+
+ LaunchedEffect(done, viewModel.hasShare) {
+ if (done || !viewModel.hasShare) navigator.pop()
+ }
+ LaunchedEffect(limitHits) {
+ if (limitHits > 0) haptics.performHapticFeedback(HapticFeedbackType.Reject)
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(CodeTheme.colors.background)
+ .statusBarsPadding()
+ .imePadding(),
+ ) {
+ AppBarWithTitle(
+ modifier = Modifier.fillMaxWidth(),
+ title = stringResource(R.string.title_shareToChat),
+ titleAlignment = Alignment.CenterHorizontally,
+ contentPadding = PaddingValues(16.dp),
+ endContent = { AppBarDefaults.Close(onClick = { navigator.pop() }) },
+ )
+ Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
+ val loaded = chats
+ when {
+ loaded == null -> {
+ // No rows until the list is known, so an early tap can't land on a row that
+ // is about to be replaced.
+ CodeCircularProgressIndicator(
+ strokeWidth = CodeTheme.dimens.thickBorder,
+ color = CodeTheme.colors.textSecondary,
+ modifier = Modifier.align(Alignment.Center).requiredSize(CodeTheme.dimens.staticGrid.x8),
+ )
+ }
+ allChats.orEmpty().isEmpty() -> ShareMessage(
+ title = stringResource(R.string.title_shareNoChats),
+ detail = stringResource(R.string.subtitle_shareNoChats),
+ )
+ else -> {
+ // Suggested chat first and not repeated below; a search is one flat list.
+ val suggested = viewModel.suggested
+ ?.takeIf { query.isEmpty() }
+ ?.let { id -> loaded.firstOrNull { it.chatId == id } }
+ val recent = if (suggested == null) loaded else loaded.filter { it.chatId != suggested.chatId }
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ item(key = "search") {
+ SearchInput(
+ state = viewModel.queryState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = ListInset)
+ .padding(top = 20.dp, bottom = CodeTheme.dimens.grid.x2),
+ )
+ }
+ if (suggested != null) {
+ item(key = "suggestedHeader") { SectionHeader(stringResource(R.string.label_suggested)) }
+ item(key = "suggested:${suggested.chatId}") {
+ ShareTargetRow(suggested, suggested.chatId in selected) { viewModel.toggle(suggested.chatId) }
+ }
+ }
+ if (loaded.isEmpty()) {
+ item(key = "empty") {
+ Text(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = ListInset).padding(top = 24.dp),
+ text = stringResource(R.string.label_shareNoMatches, query),
+ style = CodeTheme.typography.textSmall,
+ color = White50,
+ textAlign = TextAlign.Center,
+ )
+ }
+ } else if (recent.isNotEmpty()) {
+ item(key = "header") {
+ SectionHeader(
+ stringResource(if (query.isEmpty()) R.string.label_recentChats else R.string.label_chats)
+ )
+ }
+ items(recent, key = { it.chatId.toString() }) { chat ->
+ ShareTargetRow(chat, chat.chatId in selected) { viewModel.toggle(chat.chatId) }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Slides up once a chat is picked, as the iOS compose bar does.
+ val names = allChats.orEmpty().associate { it.chatId to it.name.orEmpty() }
+ AnimatedVisibility(
+ visible = selected.isNotEmpty(),
+ enter = slideInVertically { it },
+ exit = slideOutVertically { it },
+ ) {
+ val isSending = sending != null
+ val hasMessage = viewModel.messageState.text.isNotBlank()
+ ComposeBar(
+ recipients = selected.mapNotNull { names[it] }.joinToString(", "),
+ images = viewModel.images.map { it.toString() },
+ droppedImages = viewModel.droppedImageCount,
+ messageState = viewModel.messageState,
+ sending = sending,
+ progress = progress,
+ names = names,
+ canSend = viewModel.canSend(selected, isSending, progress, hasMessage),
+ onSend = { viewModel.send() },
+ )
+ }
+ }
+}
+
+@Composable
+private fun SectionHeader(title: String) {
+ Column(modifier = Modifier.padding(horizontal = ListInset)) {
+ Text(
+ modifier = Modifier.padding(vertical = 12.dp),
+ text = title,
+ style = CodeTheme.typography.textSmall,
+ color = White50,
+ )
+ HorizontalDivider(color = CodeTheme.colors.divider)
+ }
+}
+
+@Composable
+private fun ShareMessage(title: String, detail: String) {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
+ ) {
+ Text(
+ text = title,
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ text = detail,
+ style = CodeTheme.typography.textSmall,
+ color = White50,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+/** One chat, picked or not: avatar, name and the same check mark the invite sheet's rows use. */
+@Composable
+private fun ShareTargetRow(chat: ConversationReference, selected: Boolean, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .toggleable(value = selected, role = Role.RadioButton, onValueChange = { onClick() })
+ .padding(horizontal = ListInset),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ ContactAvatar(
+ image = chat.image,
+ displayName = chat.name.orEmpty(),
+ access = chat.avatarAccess,
+ modifier = Modifier
+ .requiredSize(CodeTheme.dimens.staticGrid.x6)
+ .clip(CircleShape),
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Row(
+ modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x4),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = chat.name.orEmpty(),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Image(
+ painter = painterResource(if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked),
+ contentDescription = null,
+ )
+ }
+ HorizontalDivider(color = CodeTheme.colors.divider)
+ }
+ }
+}
+
+/**
+ * Notices, then the field with the shared images stacked in it and the action, then who it goes
+ * to. The field is the chat composer's own so typing here feels the same.
+ */
+@Composable
+private fun ComposeBar(
+ recipients: String,
+ images: List,
+ droppedImages: Int,
+ messageState: TextFieldState,
+ sending: ShareSending?,
+ progress: ShareSendProgress?,
+ names: Map,
+ canSend: Boolean,
+ onSend: () -> Unit,
+) {
+ val failed = progress?.takeIf { sending == null }?.failed.orEmpty()
+ val isRetry = failed.isNotEmpty()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(CodeTheme.colors.surface)
+ .navigationBarsPadding()
+ .padding(horizontal = ListInset)
+ .padding(top = 12.dp, bottom = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (droppedImages > 0) {
+ Notice(stringResource(R.string.label_shareImageLimit, MAX_SHARE_IMAGES))
+ }
+ if (sending != null && sending.total > 1) {
+ Notice(stringResource(R.string.label_shareSendingProgress, sending.position, sending.total))
+ }
+ failed.forEach { id ->
+ val status = progress?.statuses?.get(id) as? ShareChatStatus.Failed ?: return@forEach
+ Notice(
+ text = stringResource(
+ R.string.label_shareFailure,
+ names[id].orEmpty(),
+ stringResource(status.failure.message()),
+ ),
+ color = CodeTheme.colors.error,
+ )
+ }
+ ChatInput(
+ hint = stringResource(R.string.hint_addAMessage),
+ state = messageState,
+ enabled = sending == null,
+ hasAttachments = images.isNotEmpty(),
+ sendEnabled = canSend,
+ submit = ChatInputSubmit.Action(
+ label = stringResource(
+ if (isRetry) com.flipcash.core.R.string.action_retry else com.flipcash.core.R.string.action_send
+ ),
+ busy = sending != null,
+ perform = { if (canSend) onSend() },
+ ),
+ header = if (images.isEmpty()) null else ({ ThumbnailStack(images) }),
+ )
+ Text(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
+ text = recipients,
+ style = CodeTheme.typography.textSmall,
+ color = White50,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+@Composable
+private fun Notice(text: String, color: Color = White50) {
+ Text(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
+ text = text,
+ style = CodeTheme.typography.textSmall,
+ color = color,
+ )
+}
+
+private fun ShareFailure.message(): Int = when (this) {
+ ShareFailure.Send -> R.string.error_shareSendFailed
+ ShareFailure.ImageRejected -> R.string.error_shareImageRejected
+ ShareFailure.ImageUnprepared -> R.string.error_shareImageUnprepared
+}
+
+/** Up to three of the images, fanned, with the count over them when there are more than one. */
+@Composable
+private fun ThumbnailStack(images: List) {
+ val description = stringResource(R.string.label_shareAttachedCount, images.size)
+ Box(
+ modifier = Modifier
+ .padding(start = 12.dp, top = 12.dp, end = 12.dp)
+ .size(width = 54.dp, height = 48.dp)
+ .semantics { contentDescription = description },
+ ) {
+ images.take(3).withIndex().toList().asReversed().forEach { (index, uri) ->
+ AsyncImage(
+ model = uri,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .offset(x = (index * 5).dp, y = (3 - index * 3).dp)
+ .size(44.dp)
+ .clip(ThumbShape)
+ .border(1.5.dp, CodeTheme.colors.background, ThumbShape),
+ )
+ }
+ if (images.size > 1) {
+ Text(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .clip(CircleShape)
+ .background(Color.Black.copy(alpha = 0.6f))
+ .padding(horizontal = 5.dp),
+ text = images.size.toString(),
+ style = CodeTheme.typography.textSmall,
+ color = Color.White,
+ )
+ }
+ }
+}
+
+private val ThumbShape = RoundedCornerShape(8.dp)
+private val ListInset = 20.dp
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
index 8513ef3a9..ba59cf28c 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
@@ -2607,20 +2607,26 @@ internal class ChatViewModel @AssistedInject constructor(
.launchIn(viewModelScope)
}
+ private fun stagePhotos(
+ uris: List,
+ captured: Boolean,
+ chatId: ChatId? = stateFlow.value.chatId,
+ ) {
+ chatId ?: return
+ if (stateFlow.value.editing != null) return
+ val room = MAX_STAGED_PHOTOS - stateFlow.value.stagedPhotos.size
+ uris.take(room.coerceAtLeast(0)).forEach { uri ->
+ val id = mediaUploads.stage(chatId, uri)
+ if (captured) uri.path?.let { captureFiles[id] = File(it) }
+ dispatchEvent(Event.PhotoStaged(StagedPhoto(id, uri)))
+ }
+ // Anything over the limit is not staged, and a camera file for it has no use.
+ if (captured) uris.drop(room.coerceAtLeast(0)).forEach { it.path?.let { path -> File(path).delete() } }
+ }
+
private fun initPhotoHandlers() {
eventFlow.filterIsInstance()
- .onEach { event ->
- val chatId = stateFlow.value.chatId ?: return@onEach
- if (stateFlow.value.editing != null) return@onEach
- val room = MAX_STAGED_PHOTOS - stateFlow.value.stagedPhotos.size
- event.uris.take(room.coerceAtLeast(0)).forEach { uri ->
- val id = mediaUploads.stage(chatId, uri)
- if (event.captured) uri.path?.let { captureFiles[id] = File(it) }
- dispatchEvent(Event.PhotoStaged(StagedPhoto(id, uri)))
- }
- // Anything over the limit is not staged, and a camera file for it has no use.
- if (event.captured) event.uris.drop(room.coerceAtLeast(0)).forEach { it.path?.let { path -> File(path).delete() } }
- }
+ .onEach { event -> stagePhotos(event.uris, event.captured) }
.launchIn(viewModelScope)
eventFlow.filterIsInstance()
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SharePickerViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SharePickerViewModel.kt
new file mode 100644
index 000000000..bbbaf16d6
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SharePickerViewModel.kt
@@ -0,0 +1,189 @@
+package com.flipcash.app.messenger.internal
+
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.runtime.snapshotFlow
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.user.UserManager
+import com.flipcash.shared.chat.ChatCoordinator
+import com.flipcash.shared.chat.chatListFeed
+import android.net.Uri
+import com.flipcash.shared.chat.share.ChatShareGateway
+import com.flipcash.shared.chat.share.MAX_SHARE_RECIPIENTS
+import com.flipcash.shared.chat.share.ShareRecipientSelection
+import com.flipcash.shared.chat.share.PendingChatShare
+import com.flipcash.shared.chat.share.ShareSendProgress
+import com.flipcash.shared.chat.share.ShareSender
+import com.flipcash.shared.chat.share.ShareSending
+import com.flipcash.shared.chat.share.PendingChatShares
+import com.flipcash.shared.chat.ui.ConversationReference
+import com.flipcash.shared.chat.ui.toConversationReference
+import com.getcode.util.resources.ResourceHelper
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * The chats a share from another app can go to, and the send itself. Lists the Chats tab's own set,
+ * keeps the picked chats (up to [MAX_SHARE_RECIPIENTS], in pick order) and the message, and sends
+ * through [ShareSender], which uses the same path as typing the content in each chat.
+ */
+@HiltViewModel
+internal class SharePickerViewModel @Inject constructor(
+ chatCoordinator: ChatCoordinator,
+ userManager: UserManager,
+ resources: ResourceHelper,
+ private val shares: PendingChatShares,
+ gateway: ChatShareGateway,
+) : ViewModel() {
+
+ private val sender = ShareSender(gateway)
+
+ private val _chats = MutableStateFlow?>(null)
+
+ /** Null until the feed first emits, so empty means there are no chats to offer. */
+ val chats: StateFlow?> = _chats.asStateFlow()
+
+ /** What the search field holds; the list below it is [chats] narrowed by this. */
+ val queryState = TextFieldState()
+
+ private val query = snapshotFlow { queryState.text.toString() }
+
+ /** [chats] narrowed by the search field. Null until the list is known. */
+ val visibleChats: StateFlow?> =
+ combine(_chats, query) { all, q -> all?.filterByQuery(q) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
+
+ /** The share being sent, held for the whole life of the picker. */
+ private val held: PendingChatShare? = shares.pending.value
+
+ /** False when there is no share to send, as after the process was restored; the screen closes. */
+ val hasShare: Boolean = held != null
+
+ /** The images to show and send. */
+ val images: List = held?.images.orEmpty()
+
+ /** Images past the limit that were left out. */
+ val droppedImageCount: Int = held?.droppedImageCount ?: 0
+
+ /** The chat the sender suggested, offered first, or null. */
+ val suggested: ChatId? = held?.chatId
+
+ /** The message: starts as the text that was shared and is what goes with the images. */
+ val messageState = TextFieldState(held?.text.orEmpty())
+
+ private val _selected = MutableStateFlow>(emptyList())
+
+ /** The chats the share will go to, in pick order. */
+ val selected: StateFlow> = _selected.asStateFlow()
+
+ val progress: StateFlow = sender.progress
+
+ val sending: StateFlow = sender.sending
+
+ private val _done = MutableStateFlow(false)
+
+ /** True once every chat has the share, so the screen can close. */
+ val done: StateFlow = _done.asStateFlow()
+
+ private val _limitHits = MutableStateFlow(0)
+
+ /** Counts taps on a row once the selection was full, for haptic feedback. */
+ val limitHits: StateFlow = _limitHits.asStateFlow()
+
+ private var started = false
+ private var sendJob: Job? = null
+
+ init {
+ viewModelScope.launch {
+ val selfId = userManager.accountId
+ chatCoordinator.chatListFeed().collect { summaries ->
+ val fresh = summaries.map { it.toConversationReference(selfId, emptyMap(), resources) }
+ // The first list is shown as it arrives. Later ones (the feed keeps syncing) keep
+ // the rows already on screen where they are, so nothing moves under a finger.
+ val first = _chats.value == null
+ _chats.value = fresh.mergeStable(_chats.value) { it.chatId }
+ if (first && suggested != null && fresh.any { it.chatId == suggested }) select(suggested)
+ }
+ }
+ }
+
+ /** Whether there is something to send and somewhere to send it, and no send under way. */
+ fun canSend(selected: List, sending: Boolean, progress: ShareSendProgress?, hasMessage: Boolean): Boolean {
+ if (sending || selected.isEmpty()) return false
+ if (progress?.isStuck == true) return false
+ return images.isNotEmpty() || hasMessage
+ }
+
+ private fun select(chatId: ChatId) {
+ if (chatId !in _selected.value) _selected.update { ShareRecipientSelection(it).toggle(chatId).first.ids }
+ }
+
+ /** Picks [chatId], or drops it when already picked. A full selection refuses new picks. */
+ fun toggle(chatId: ChatId) {
+ if (sender.sending.value != null) return
+ val (next, result) = ShareRecipientSelection(_selected.value).toggle(chatId)
+ if (result == ShareRecipientSelection.Toggle.LimitReached) {
+ _limitHits.update { it + 1 }
+ return
+ }
+ _selected.value = next.ids
+ // The set changed under a failed send; the next send starts from the new set.
+ if (sender.progress.value?.failed?.isNotEmpty() == true) sender.reset()
+ }
+
+ /** Sends to every picked chat that hasn't got the share, and closes once all have. */
+ fun send() {
+ if (sendJob?.isActive == true) return
+ val recipients = _selected.value
+ if (recipients.isEmpty()) return
+ started = true
+ val message = messageState.text.toString()
+ sendJob = viewModelScope.launch {
+ if (sender.send(recipients, images, message)) {
+ // Past this point every image has been encoded and posted, so the copies can go.
+ shares.discard()
+ _done.value = true
+ }
+ }
+ }
+
+ override fun onCleared() {
+ // Left without sending (back, close): nothing was asked for, so the share is dropped and
+ // its copied images with it. Once a send started the images belong to it, so they stay.
+ if (_done.value) return
+ if (started) shares.release() else shares.discard()
+ }
+}
+
+/**
+ * This list with the order of [previous] kept: rows still present stay in their old positions with
+ * their new contents, rows that went are dropped, and new ones are appended in their own order.
+ * With no [previous] it is this list unchanged.
+ */
+internal fun List.mergeStable(previous: List?, key: (T) -> K): List {
+ if (previous == null) return this
+ val byKey = associateBy(key)
+ val kept = previous.mapNotNull { byKey[key(it)] }
+ val keptKeys = kept.mapTo(HashSet(), key)
+ return kept + filterNot { key(it) in keptKeys }
+}
+
+/**
+ * The chats whose name contains [query], ignoring case and surrounding space. A blank query keeps
+ * them all.
+ */
+internal fun List.filterByQuery(query: String): List {
+ val needle = query.trim()
+ if (needle.isEmpty()) return this
+ return filter { it.name.orEmpty().contains(needle, ignoreCase = true) }
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SharePickerMergeTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SharePickerMergeTest.kt
new file mode 100644
index 000000000..edc77dc03
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SharePickerMergeTest.kt
@@ -0,0 +1,45 @@
+package com.flipcash.app.messenger.internal
+
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.shared.chat.ui.ConversationReference
+import com.getcode.opencode.model.core.bytes
+import java.util.UUID
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SharePickerMergeTest {
+
+ private fun List.merge(previous: List?) = mergeStable(previous) { it.first() }
+
+ @Test
+ fun `no previous list is taken as is`() {
+ assertEquals(listOf("b1", "a1"), listOf("b1", "a1").merge(null))
+ }
+
+ @Test
+ fun `rows keep their old positions and take new contents`() {
+ assertEquals(listOf("b2", "a2"), listOf("a2", "b2").merge(listOf("b1", "a1")))
+ }
+
+ @Test
+ fun `gone rows are dropped and new rows are appended in order`() {
+ assertEquals(listOf("b1", "c1", "d1"), listOf("c1", "b1", "d1").merge(listOf("a1", "b1")))
+ }
+
+ private fun group(title: String) =
+ ConversationReference(chatId = ChatId(UUID.randomUUID().bytes), title = title, isGroup = true)
+
+ @Test
+ fun `blank query keeps every chat`() {
+ val chats = listOf(group("Alpha"), group("Beta"))
+ assertEquals(chats, chats.filterByQuery(" "))
+ }
+
+ @Test
+ fun `query matches names ignoring case and surrounding space`() {
+ val alpha = group("Alpha Club")
+ val chats = listOf(alpha, group("Beta"))
+ assertEquals(listOf(alpha), chats.filterByQuery(" alPHA "))
+ assertEquals(emptyList(), chats.filterByQuery("zzz"))
+ }
+}
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatShareIntake.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatShareIntake.kt
new file mode 100644
index 000000000..824fd6c11
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatShareIntake.kt
@@ -0,0 +1,47 @@
+package com.flipcash.shared.chat.ui.media
+
+import android.content.Context
+import android.content.Intent
+import com.flipcash.shared.chat.share.PendingChatShare
+import com.flipcash.shared.chat.share.PendingChatShares
+import com.flipcash.shared.chat.share.toChatShareRequest
+
+/**
+ * Takes a share intent into [shares]. Returns false when [intent] is not a share we can stage.
+ *
+ * The images are copied into the capture directory before this returns, while the intent's read
+ * grant is certain to be held: it is scoped to the receiving activity and does not outlive it, and
+ * the picker, the navigation and a process restart all sit between here and the composer. Only the
+ * first [MAX_STAGED_PHOTOS] are copied, since the composer would refuse the rest.
+ */
+suspend fun receiveChatShare(context: Context, intent: Intent, shares: PendingChatShares): Boolean {
+ val staged = try {
+ stageChatShare(context, intent, shares)
+ } catch (t: Throwable) {
+ shares.cancelIncoming()
+ throw t
+ }
+ // Whatever the outcome, nobody should keep waiting for a share that is not coming.
+ if (!staged) shares.cancelIncoming()
+ return staged
+}
+
+private suspend fun stageChatShare(context: Context, intent: Intent, shares: PendingChatShares): Boolean {
+ val resolver = context.contentResolver
+ val request = intent.toChatShareRequest { runCatching { resolver.getType(it) }.getOrNull() }
+ ?: return false
+ val copies = copyDroppedImages(request.imageSources.take(MAX_STAGED_PHOTOS)) {
+ copyIntoCaptureFile(context, it)
+ }
+ // Images were all that was shared and none could be read: there is nothing to stage.
+ if (request.text == null && copies.isEmpty()) return false
+ shares.offer(
+ PendingChatShare(
+ text = request.text,
+ images = copies,
+ chatId = com.flipcash.shared.chat.share.chatIdFromShortcutId(request.shortcutId),
+ droppedImageCount = (request.imageSources.size - MAX_STAGED_PHOTOS).coerceAtLeast(0),
+ )
+ )
+ return true
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ChatShare.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ChatShare.kt
new file mode 100644
index 000000000..141e48a70
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ChatShare.kt
@@ -0,0 +1,174 @@
+package com.flipcash.shared.chat.share
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.runtime.staticCompositionLocalOf
+import com.flipcash.services.models.chat.ChatId
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * What another app handed us through the system share sheet, as it arrived: the text and the
+ * content URIs of the images, before anything is copied. The URIs are readable only under the
+ * grant that came with the intent, so [ChatShareRequest] is turned into a [PendingChatShare] on
+ * receipt and never kept.
+ *
+ * @param shortcutId `Intent.EXTRA_SHORTCUT_ID`, set when the user picked one of our direct-share
+ * targets. Nothing publishes those yet; see [chatIdFromShortcutId].
+ */
+data class ChatShareRequest(
+ val text: String?,
+ val imageSources: List,
+ val shortcutId: String? = null,
+)
+
+/**
+ * Reads a share into a [ChatShareRequest], or null when there is nothing in it we can stage.
+ *
+ * Only ACTION_SEND and ACTION_SEND_MULTIPLE are shares. A stream is taken only when [mimeOf] says
+ * an image type for it: the manifest filter admits an image type, but a sender can attach anything,
+ * and an unknown type is not taken. Blank text is dropped.
+ */
+fun parseChatShare(
+ action: String?,
+ text: CharSequence?,
+ streams: List,
+ shortcutId: String? = null,
+ mimeOf: (Uri) -> String?,
+): ChatShareRequest? {
+ if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
+ val body = text?.toString()?.trim()?.takeIf { it.isNotEmpty() }
+ val images = streams.filter { runCatching { mimeOf(it) }.getOrNull()?.startsWith("image/") == true }
+ if (body == null && images.isEmpty()) return null
+ return ChatShareRequest(body, images, shortcutId)
+}
+
+/** [parseChatShare] for a received [Intent]. */
+@Suppress("DEPRECATION")
+fun Intent.toChatShareRequest(mimeOf: (Uri) -> String?): ChatShareRequest? {
+ val streams = when (action) {
+ Intent.ACTION_SEND_MULTIPLE ->
+ getParcelableArrayListExtra(Intent.EXTRA_STREAM).orEmpty()
+ else -> listOfNotNull(getParcelableExtra(Intent.EXTRA_STREAM))
+ }
+ return parseChatShare(
+ action = action,
+ text = getCharSequenceExtra(Intent.EXTRA_TEXT),
+ streams = streams,
+ shortcutId = getStringExtra(Intent.EXTRA_SHORTCUT_ID),
+ mimeOf = mimeOf,
+ )
+}
+
+/**
+ * The seam for direct share: the conversation a sharing shortcut stands for, so the picker can be
+ * skipped. Always null until shortcuts are published; the later change maps the id it chose for
+ * each shortcut back to a [ChatId] here and nothing else has to move.
+ */
+@Suppress("UNUSED_PARAMETER")
+fun chatIdFromShortcutId(shortcutId: String?): ChatId? = null
+
+/**
+ * A share ready to send: [text] for the message field and [images], which are app-owned files under
+ * the capture directory, safe to read after the sender's grant is gone.
+ *
+ * [chatId] is the chat the sender suggested, which the picker offers first and selects; null when
+ * nothing was suggested. [droppedImageCount] is how many images past the limit were left out.
+ */
+data class PendingChatShare(
+ val text: String?,
+ val images: List,
+ val chatId: ChatId? = null,
+ val droppedImageCount: Int = 0,
+ /** Set once the picker (or a direct target) has been opened for it, so it is opened once. */
+ val presented: Boolean = false,
+) {
+ internal fun deleteFiles() {
+ images.forEach { uri -> uri.path?.let { runCatching { File(it).delete() } } }
+ }
+}
+
+/**
+ * The one share waiting for the picker. A holder rather than route arguments: the picker route
+ * lives in the back stack and is saved and restored with it, so anything on it would be presented
+ * again every time it is restored. Here, [beginPresenting] claims the share once.
+ *
+ * Process-scoped on purpose. A share that doesn't survive process death is dropped, which is the
+ * right outcome: its files are cache and nothing was sent.
+ */
+@Singleton
+class PendingChatShares @Inject constructor() {
+ private val _pending = MutableStateFlow(null)
+ val pending: StateFlow = _pending.asStateFlow()
+
+ private val _incoming = MutableStateFlow(false)
+
+ /**
+ * True from the moment a share intent arrives until its images are copied and it is either
+ * [offer]ed or given up on ([cancelIncoming]). Copying is asynchronous, so without this the
+ * first screen of a share launch is chosen before [pending] is known and the app opens on its
+ * ordinary start screen with the picker pushed over it a beat later.
+ */
+ val incoming: StateFlow = _incoming.asStateFlow()
+
+ /** Marks a share as on its way in, before [pending] holds it. */
+ fun expectIncoming() {
+ _incoming.value = true
+ }
+
+ /** The share that was expected turned out to hold nothing to stage. */
+ fun cancelIncoming() {
+ _incoming.value = false
+ }
+
+ /** Holds [share], dropping (and deleting the files of) any share it replaces. */
+ fun offer(share: PendingChatShare) {
+ _pending.getAndUpdateCompat { share }?.deleteFiles()
+ _incoming.value = false
+ }
+
+ /** Claims the share for presenting, once. Null when there is none or it was already claimed. */
+ fun beginPresenting(): PendingChatShare? {
+ var claimed: PendingChatShare? = null
+ _pending.update { current ->
+ if (current == null || current.presented) {
+ claimed = null
+ current
+ } else {
+ current.copy(presented = true).also { claimed = it }
+ }
+ }
+ return claimed
+ }
+
+ /**
+ * Lets go of the held share without deleting its files: they belong to sends already under
+ * way, which read them from the app's own scope after the picker is gone.
+ */
+ fun release() {
+ _pending.value = null
+ _incoming.value = false
+ }
+
+ /** Drops the held share and deletes its files: the user backed out, or can't receive it. */
+ fun discard() {
+ _pending.getAndUpdateCompat { null }?.deleteFiles()
+ _incoming.value = false
+ }
+
+ private inline fun MutableStateFlow.getAndUpdateCompat(
+ transform: (PendingChatShare?) -> PendingChatShare?,
+ ): PendingChatShare? {
+ while (true) {
+ val prev = value
+ if (compareAndSet(prev, transform(prev))) return prev
+ }
+ }
+}
+
+val LocalPendingChatShares = staticCompositionLocalOf { null }
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ChatShareGateway.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ChatShareGateway.kt
new file mode 100644
index 000000000..eccba8398
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ChatShareGateway.kt
@@ -0,0 +1,46 @@
+package com.flipcash.shared.chat.share
+
+import android.net.Uri
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.shared.chat.ChatCoordinator
+import com.flipcash.shared.chat.media.ChatMediaUploads
+import com.flipcash.shared.chat.media.MediaSendProgress
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+
+/**
+ * [ShareGateway] over the calls the chat screen itself makes: [ChatCoordinator.sendMessage] for
+ * text, [ChatMediaUploads.stage] and [ChatCoordinator.sendMedia] for photos. Both consult
+ * `OutgoingEncryption`, so a chat that encrypts gets sealed messages and sealed photo uploads here
+ * exactly as when the same content is typed in it.
+ */
+class ChatShareGateway @Inject constructor(
+ private val coordinator: ChatCoordinator,
+ private val uploads: ChatMediaUploads,
+) : ShareGateway {
+
+ override suspend fun sendText(chatId: ChatId, text: String): Result =
+ coordinator.sendMessage(chatId, text).map { }
+
+ override fun stagePhoto(chatId: ChatId, uri: Uri): String = uploads.stage(chatId, uri)
+
+ override suspend fun sendPhotos(chatId: ChatId, chipIds: List, caption: String): Result> =
+ coordinator.sendMedia(chatId, chipIds, caption, replyToMessageId = null)
+
+ override suspend fun awaitPhotos(messageIds: List): Map {
+ val settled = coordinator.observeMediaSendProgress().first { progress ->
+ messageIds.all { progress[it] is MediaSendProgress.Sent || progress[it] is MediaSendProgress.Failed }
+ }
+ return messageIds.associateWith { id ->
+ when (val state = settled[id]) {
+ is MediaSendProgress.Failed -> PhotoOutcome.Failed(state.retryable)
+ else -> PhotoOutcome.Sent
+ }
+ }
+ }
+
+ override suspend fun retryPhoto(chatId: ChatId, messageId: String): Result =
+ coordinator.retryMedia(chatId, messageId)
+
+ override fun discardPhotos(chipIds: List) = chipIds.forEach { uploads.remove(it) }
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ShareRecipientSelection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ShareRecipientSelection.kt
new file mode 100644
index 000000000..0ed18e6b5
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ShareRecipientSelection.kt
@@ -0,0 +1,18 @@
+package com.flipcash.shared.chat.share
+
+import com.flipcash.services.models.chat.ChatId
+
+/** The chats a share is addressed to, in the order they were picked, capped at [MAX_SHARE_RECIPIENTS]. */
+data class ShareRecipientSelection(val ids: List = emptyList()) {
+
+ enum class Toggle { Selected, Deselected, LimitReached }
+
+ val isFull: Boolean get() = ids.size >= MAX_SHARE_RECIPIENTS
+
+ /** Picks [id], or drops it when already picked. A full selection refuses a new pick. */
+ fun toggle(id: ChatId): Pair = when {
+ id in ids -> copy(ids = ids - id) to Toggle.Deselected
+ isFull -> this to Toggle.LimitReached
+ else -> copy(ids = ids + id) to Toggle.Selected
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ShareSender.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ShareSender.kt
new file mode 100644
index 000000000..d545e7ccd
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ShareSender.kt
@@ -0,0 +1,206 @@
+package com.flipcash.shared.chat.share
+
+import android.net.Uri
+import com.flipcash.services.models.chat.ChatId
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Most chats one share can go to, as on iOS. */
+const val MAX_SHARE_RECIPIENTS = 5
+
+/** Most images one share carries; the same ceiling as the composer's attachments. */
+const val MAX_SHARE_IMAGES = 10
+
+/** Why a chat didn't get the share, for the line under the compose bar. */
+enum class ShareFailure {
+ /** The send failed in a way trying again may fix. */
+ Send,
+
+ /** The server refused an image, as moderation does. */
+ ImageRejected,
+
+ /** An image couldn't be read or prepared. */
+ ImageUnprepared,
+}
+
+/** Where one chosen chat stands once a send has started. */
+sealed interface ShareChatStatus {
+ data object Pending : ShareChatStatus
+ data object Sent : ShareChatStatus
+ data class Failed(val failure: ShareFailure, val canRetry: Boolean) : ShareChatStatus
+}
+
+/** Where every chosen chat stands, in the order they were picked. */
+data class ShareSendProgress(
+ val order: List,
+ val statuses: Map = order.associateWith { ShareChatStatus.Pending },
+) {
+ /** Chats still to send to: pending, or failed with an error trying again may fix. */
+ val remaining: List
+ get() = order.filter {
+ when (val status = statuses[it]) {
+ ShareChatStatus.Pending -> true
+ is ShareChatStatus.Failed -> status.canRetry
+ ShareChatStatus.Sent, null -> false
+ }
+ }
+
+ val failed: List
+ get() = order.filter { statuses[it] is ShareChatStatus.Failed }
+
+ val isComplete: Boolean get() = order.all { statuses[it] == ShareChatStatus.Sent }
+
+ /** Something failed and nothing retryable is left to try. */
+ val isStuck: Boolean get() = !isComplete && remaining.isEmpty()
+
+ fun with(chatId: ChatId, status: ShareChatStatus) = copy(statuses = statuses + (chatId to status))
+}
+
+/** The chat being sent to, `position` of `total`, counting from 1. */
+data class ShareSending(val position: Int, val total: Int)
+
+/** A photo message's end state once it has left the device. */
+sealed interface PhotoOutcome {
+ data object Sent : PhotoOutcome
+ data class Failed(val retryable: Boolean) : PhotoOutcome
+}
+
+/**
+ * The app's own send path as [ShareSender] needs it. Everything that seals content for an
+ * encrypted chat lives behind these calls (see `OutgoingEncryption`); the sender decides only
+ * what goes where and in which order.
+ */
+interface ShareGateway {
+ /** Sends [text] to [chatId] the way typing it there would. */
+ suspend fun sendText(chatId: ChatId, text: String): Result
+
+ /**
+ * Starts preparing and uploading [uri] for [chatId] and returns its chip. The upload is sealed
+ * for [chatId] when that chat encrypts, so a chip belongs to one chat and is never shared.
+ */
+ fun stagePhoto(chatId: ChatId, uri: Uri): String
+
+ /**
+ * Posts the staged [chipIds] to [chatId], one message each with [caption] on the last, and
+ * returns the id of each message in chip order once they are queued.
+ */
+ suspend fun sendPhotos(chatId: ChatId, chipIds: List, caption: String): Result>
+
+ /** Suspends until every message in [messageIds] has been sent or has failed. */
+ suspend fun awaitPhotos(messageIds: List): Map
+
+ /** Tries a failed photo message again. */
+ suspend fun retryPhoto(chatId: ChatId, messageId: String): Result
+
+ /** Drops chips that never made it into a send. */
+ fun discardPhotos(chipIds: List)
+}
+
+/**
+ * Sends one share to several chats, as the iOS share extension does: chat by chat, in the order
+ * they were picked, one failure never stopping the rest, and a retry going only to the chats that
+ * haven't received it. Images become one photo message each with the message as the caption of the
+ * last; with no images the message is a text. Nothing is sent twice: within a chat, a retry
+ * re-attempts only the photo messages that failed.
+ *
+ * Holds the send's progress between calls, so one instance serves one share.
+ */
+class ShareSender(private val gateway: ShareGateway) {
+
+ private val _progress = MutableStateFlow(null)
+
+ /** Null before the first send; after, where each chosen chat stands. */
+ val progress: StateFlow = _progress.asStateFlow()
+
+ private val _sending = MutableStateFlow(null)
+
+ /** Non-null while a send is under way. */
+ val sending: StateFlow = _sending.asStateFlow()
+
+ private class Work {
+ var textSent = false
+ var messages: List? = null
+ val sent = HashSet()
+ }
+
+ private val work = HashMap()
+
+ /** Forgets progress, as when the chosen chats change after a failure. */
+ fun reset() {
+ _progress.value = null
+ work.clear()
+ }
+
+ /**
+ * Sends [text] and [images] to every chat in [recipients] that hasn't got them yet. True once
+ * every chat has. Images past [MAX_SHARE_IMAGES] are not sent.
+ */
+ suspend fun send(recipients: List, images: List, text: String): Boolean {
+ val photos = images.take(MAX_SHARE_IMAGES)
+ val message = text.trim()
+ if (photos.isEmpty() && message.isEmpty()) return false
+
+ var progress = _progress.value ?: ShareSendProgress(recipients)
+ val targets = progress.remaining
+ try {
+ targets.forEachIndexed { index, chatId ->
+ _sending.value = ShareSending(index + 1, targets.size)
+ val status = try {
+ deliver(chatId, photos, message)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ ShareChatStatus.Failed(ShareFailure.Send, canRetry = true)
+ }
+ progress = progress.with(chatId, status)
+ _progress.value = progress
+ }
+ } finally {
+ _sending.value = null
+ _progress.value = progress
+ }
+ return progress.isComplete
+ }
+
+ private suspend fun deliver(chatId: ChatId, images: List, text: String): ShareChatStatus {
+ val state = work.getOrPut(chatId) { Work() }
+ if (images.isEmpty()) {
+ if (!state.textSent) {
+ if (gateway.sendText(chatId, text).isFailure) return ShareChatStatus.Failed(ShareFailure.Send, true)
+ state.textSent = true
+ }
+ return ShareChatStatus.Sent
+ }
+
+ val queued = state.messages
+ val waitFor: List
+ if (queued == null) {
+ // Staged per chat: a photo sealed for one chat is that chat's alone.
+ val chips = images.map { gateway.stagePhoto(chatId, it) }
+ val ids = gateway.sendPhotos(chatId, chips, text).getOrElse {
+ gateway.discardPhotos(chips)
+ return ShareChatStatus.Failed(ShareFailure.Send, true)
+ }
+ state.messages = ids
+ waitFor = ids
+ } else {
+ waitFor = queued.filter { it !in state.sent }
+ for (id in waitFor) {
+ if (gateway.retryPhoto(chatId, id).isFailure) {
+ return ShareChatStatus.Failed(ShareFailure.ImageUnprepared, canRetry = false)
+ }
+ }
+ }
+
+ val outcomes = gateway.awaitPhotos(waitFor)
+ outcomes.forEach { (id, outcome) -> if (outcome == PhotoOutcome.Sent) state.sent += id }
+ val failures = outcomes.values.filterIsInstance()
+ return when {
+ failures.isEmpty() && state.sent.containsAll(waitFor) -> ShareChatStatus.Sent
+ failures.any { it.retryable } || failures.isEmpty() -> ShareChatStatus.Failed(ShareFailure.Send, true)
+ else -> ShareChatStatus.Failed(ShareFailure.ImageRejected, false)
+ }
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareGatewayTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareGatewayTest.kt
new file mode 100644
index 000000000..6727aabc9
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareGatewayTest.kt
@@ -0,0 +1,230 @@
+package com.flipcash.shared.chat.share
+
+import android.net.Uri
+import com.flipcash.app.blob.BlobStorageCoordinator
+import com.flipcash.app.blob.ChatMediaEncoder
+import com.flipcash.app.blob.ChatMediaSealing
+import com.flipcash.app.persistence.entities.ChatMetadataEntity
+import com.flipcash.app.persistence.sources.ChatMemberDataSource
+import com.flipcash.app.persistence.sources.ChatMessageDataSource
+import com.flipcash.app.persistence.sources.ChatMetadataDataSource
+import com.flipcash.app.persistence.sources.PendingMessage
+import com.flipcash.services.chat.ChatContentCrypto
+import com.flipcash.services.chat.ChatKeySource
+import com.flipcash.services.chat.E2eePolicy
+import com.flipcash.services.chat.FakeChatCipher
+import com.flipcash.services.controllers.ChatController
+import com.flipcash.services.controllers.ChatMessagingController
+import com.flipcash.services.models.UserProfile
+import com.flipcash.services.models.blob.EncryptedConstraints
+import com.flipcash.services.models.blob.MimeTypeConstraints
+import com.flipcash.services.models.blob.UploadPolicy
+import com.flipcash.services.models.chat.BlobId
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMember
+import com.flipcash.services.models.chat.ChatMessage
+import com.flipcash.services.models.chat.ChatMetadata
+import com.flipcash.services.models.chat.ChatType
+import com.flipcash.services.models.chat.ClientMessageId
+import com.flipcash.services.models.chat.MessageContent
+import com.flipcash.services.user.UserManager
+import com.flipcash.shared.chat.ChatCoordinator
+import com.flipcash.shared.chat.internal.DmOutgoingEncryption
+import com.flipcash.shared.chat.internal.delegates.MessagingDelegate
+import com.flipcash.shared.chat.media.ChatMediaUploadState
+import com.flipcash.shared.chat.media.ChatMediaUploads
+import com.getcode.ed25519kmp.KeyPair
+import com.getcode.opencode.model.core.ID
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNull
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Instant
+
+/**
+ * [ChatShareGateway] over the real encryption layer ([DmOutgoingEncryption] with the real
+ * [E2eePolicy]), so what the share picker sends is held to the same rule as typing in the chat:
+ * a chat that encrypts gets sealed text and sealed photo uploads, one that doesn't gets plain ones.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class ChatShareGatewayTest {
+
+ @get:Rule val folder = TemporaryFolder()
+
+ private val encrypted = ChatId(ByteArray(32) { 3 })
+ private val otherEncrypted = ChatId(ByteArray(32) { 5 })
+ private val plain = ChatId(ByteArray(32) { 4 })
+
+ private val selfId: ID = List(16) { 1 }
+ private val peerId: ID = List(16) { 2 }
+ private val selfKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 })
+
+ private val keys = object : ChatKeySource {
+ override fun ownKeyPair() = selfKeys
+ override suspend fun peerPublicKey(userId: ID) = Result.success(ByteArray(32) { 21 })
+ }
+
+ private val chats = mapOf(
+ encrypted to chat(encrypted, useE2ee = true),
+ otherEncrypted to chat(otherEncrypted, useE2ee = true),
+ plain to chat(plain, useE2ee = false),
+ )
+
+ private fun chat(id: ChatId, useE2ee: Boolean) = ChatMetadata(
+ chatId = id,
+ type = ChatType.CONTACT_DM,
+ members = listOf(member(selfId), member(peerId)),
+ lastMessage = null,
+ lastActivity = Instant.fromEpochSeconds(0),
+ useE2ee = useE2ee,
+ )
+
+ private fun member(id: ID) = ChatMember(userId = id, userProfile = UserProfile.Empty, pointers = emptyList())
+
+ private val userManager = mockk(relaxed = true).also { every { it.accountId } returns selfId }
+ private val chatController = mockk(relaxed = true)
+ private val metadata = mockk(relaxed = true).also { m ->
+ every { m.observeById(any()) } answers { flowOf(mockk()) }
+ every { m.toMetadata(any(), any(), any()) } returns chats.getValue(encrypted)
+ }
+ private val members = mockk(relaxed = true)
+ private val messages = mockk(relaxed = true)
+ private val messaging = mockk(relaxed = true)
+
+ /** The wire content of each text send, by chat. */
+ private val wires = LinkedHashMap>()
+
+ private val outgoing = DmOutgoingEncryption(
+ policy = E2eePolicy(),
+ crypto = ChatContentCrypto(FakeChatCipher, keys),
+ userManager = userManager,
+ chatController = chatController,
+ metadataDataSource = metadata,
+ memberDataSource = members,
+ )
+
+ private fun serverCopy(content: List) = ChatMessage(
+ messageId = 10, senderId = selfId, content = content,
+ timestamp = Instant.fromEpochSeconds(2_000), unreadSeq = 0, eventSequence = 5, isFromSelf = true,
+ )
+
+ private val encoder = mockk {
+ coEvery { encode(any(), any()) } returns Result.success(ChatMediaEncoder.Encoded(byteArrayOf(1, 2, 3), 100, 50))
+ }
+
+ /** The sealing each upload was handed, in upload order. */
+ private val sealings = ArrayList()
+ private val blobs = mockk {
+ every { policy } returns MutableStateFlow(
+ UploadPolicy("v1", 1.hours, listOf(MimeTypeConstraints("image/jpeg", 1_000_000, null)), EncryptedConstraints(1_000_000, null)),
+ )
+ coEvery { storeChatMediaUnfinalized(any(), any(), any(), any()) } coAnswers {
+ sealings += secondArg()
+ Result.success(BlobId(byteArrayOf(9, 9)))
+ }
+ coEvery { awaitChatMediaReady(any()) } returns Result.success(BlobId(byteArrayOf(9, 9)))
+ }
+
+ private fun TestScope.gateway(): Pair {
+ every { metadata.toMetadata(any(), any(), any()) } answers { chats.getValue(currentChat) }
+ coEvery { members.getMembersForChat(any()) } coAnswers { chats.getValue(arg(0)).members }
+ every { metadata.observeById(any()) } answers {
+ currentChat = arg(0)
+ flowOf(mockk())
+ }
+ coEvery { messages.insertPending(any(), any(), any()) } answers {
+ PendingMessage(serverCopy(secondArg()), ClientMessageId(ByteArray(16) { 9 }))
+ }
+ coEvery { messaging.sendMessage(any(), any(), any()) } answers {
+ wires[arg(0)] = arg>(1)
+ Result.success(serverCopy(secondArg()))
+ }
+ val delegate = MessagingDelegate(
+ chatController = chatController,
+ messagingController = messaging,
+ metadataDataSource = metadata,
+ messageDataSource = messages,
+ memberDataSource = members,
+ notificationManager = mockk(relaxed = true),
+ userManager = userManager,
+ stateHolder = mockk(relaxed = true),
+ analytics = mockk(relaxed = true),
+ senderResolver = mockk(relaxed = true),
+ outgoing = outgoing,
+ )
+ val coordinator = mockk {
+ coEvery { sendMessage(any(), any(), any()) } coAnswers {
+ delegate.sendMessage(firstArg(), secondArg(), thirdArg())
+ }
+ }
+ val uploads = ChatMediaUploads(
+ directory = { File(folder.root, "pending-media") },
+ encoder = encoder, blobs = blobs, outgoing = outgoing,
+ sourceSize = { 4000 to 3000 }, blurhashOf = { null },
+ scope = backgroundScope, newId = { "chip${next++}" },
+ )
+ return ChatShareGateway(coordinator, uploads) to uploads
+ }
+
+ private var currentChat: ChatId = encrypted
+ private var next = 0
+
+ @Test
+ fun `text to an encrypting chat is sealed and to a plain chat is not`() = runTest {
+ val (gateway, _) = gateway()
+
+ gateway.sendText(encrypted, "hello").getOrThrow()
+ gateway.sendText(plain, "hello").getOrThrow()
+
+ assertIs(wires.getValue(encrypted).single())
+ assertEquals(listOf(MessageContent.Text("hello")), wires.getValue(plain))
+ }
+
+ @Test
+ fun `a photo is uploaded sealed for the encrypting chat and plain for the other`() = runTest {
+ val (gateway, uploads) = gateway()
+ val uri = Uri.parse("content://photos/1")
+
+ val sealedChip = gateway.stagePhoto(encrypted, uri)
+ val plainChip = gateway.stagePhoto(plain, uri)
+ runCurrent()
+
+ val sealed = assertIs(uploads.current(sealedChip)).photo
+ val unsealed = assertIs(uploads.current(plainChip)).photo
+ assertEquals(encrypted, sealed.sealedFor)
+ assertNull(unsealed.sealedFor)
+ assertEquals(listOf(encrypted, null), sealings.map { it?.chatId })
+ }
+
+ @Test
+ fun `the same image staged for two encrypting chats is sealed separately`() = runTest {
+ val (gateway, uploads) = gateway()
+ val uri = Uri.parse("content://photos/1")
+
+ val first = gateway.stagePhoto(encrypted, uri)
+ val second = gateway.stagePhoto(otherEncrypted, uri)
+ runCurrent()
+
+ assertNotEquals(first, second)
+ assertEquals(encrypted, assertIs(uploads.current(first)).photo.sealedFor)
+ assertEquals(otherEncrypted, assertIs(uploads.current(second)).photo.sealedFor)
+ assertEquals(listOf(encrypted, otherEncrypted), sealings.map { it?.chatId })
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareTest.kt
new file mode 100644
index 000000000..c651b13ee
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareTest.kt
@@ -0,0 +1,136 @@
+package com.flipcash.shared.chat.share
+
+import android.content.Intent
+import android.net.Uri
+import com.flipcash.services.models.chat.ChatId
+import com.getcode.opencode.model.core.bytes
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+import java.util.UUID
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class ChatShareTest {
+
+ private val png = Uri.parse("content://other.app/shot.png")
+ private val jpg = Uri.parse("content://other.app/pic.jpg")
+ private val pdf = Uri.parse("content://other.app/doc.pdf")
+ private val unknown = Uri.parse("content://other.app/mystery")
+ private val types = mapOf(png to "image/png", jpg to "image/jpeg", pdf to "application/pdf")
+ private val mimeOf: (Uri) -> String? = { types[it] }
+
+ private fun parse(
+ action: String? = Intent.ACTION_SEND,
+ text: CharSequence? = null,
+ streams: List = emptyList(),
+ ) = parseChatShare(action, text, streams, mimeOf = mimeOf)
+
+ @Test
+ fun singleImage() {
+ assertEquals(ChatShareRequest(null, listOf(png)), parse(streams = listOf(png)))
+ }
+
+ @Test
+ fun multipleImages() {
+ val request = parse(Intent.ACTION_SEND_MULTIPLE, streams = listOf(png, jpg))
+ assertEquals(listOf(png, jpg), request?.imageSources)
+ }
+
+ @Test
+ fun textOnly() {
+ assertEquals(ChatShareRequest("https://flipcash.com", emptyList()), parse(text = " https://flipcash.com\n"))
+ }
+
+ @Test
+ fun mixedTextAndImage() {
+ assertEquals(ChatShareRequest("look", listOf(png)), parse(text = "look", streams = listOf(png)))
+ }
+
+ @Test
+ fun nonImageStreamIsRejected() {
+ assertNull(parse(streams = listOf(pdf)))
+ assertNull(parse(streams = listOf(unknown)))
+ }
+
+ @Test
+ fun nonImageStreamIsDroppedButTextKept() {
+ assertEquals(ChatShareRequest("note", listOf(png)), parse(text = "note", streams = listOf(pdf, png)))
+ }
+
+ @Test
+ fun blankTextWithNoImagesIsNothing() {
+ assertNull(parse(text = " "))
+ }
+
+ @Test
+ fun otherActionsAreNotShares() {
+ assertNull(parse(action = Intent.ACTION_VIEW, text = "hi"))
+ assertNull(parse(action = null, text = "hi"))
+ }
+
+ @Test
+ fun shortcutIdMapsToNoChatYet() {
+ assertNull(chatIdFromShortcutId("anything"))
+ }
+
+ // --- PendingChatShares -------------------------------------------------------------------
+
+ private val chatA = ChatId(UUID.fromString("0189d5f1-3c6a-7b4e-8f21-9c3d4e5f6a7b").bytes)
+ private val chatB = ChatId(UUID.fromString("0189d5f1-3c6a-7b4e-8f21-9c3d4e5f6a7c").bytes)
+ private val share = PendingChatShare("hi", emptyList())
+
+ @Test
+ fun incomingHoldsFromExpectUntilOfferedOrCancelled() {
+ val shares = PendingChatShares()
+ assertEquals(false, shares.incoming.value)
+ shares.expectIncoming()
+ assertEquals(true, shares.incoming.value)
+ shares.offer(share)
+ assertEquals(false, shares.incoming.value)
+ shares.expectIncoming()
+ shares.cancelIncoming()
+ assertEquals(false, shares.incoming.value)
+ }
+
+ @Test
+ fun releaseClearsWithoutDeletingTheCopies() {
+ val file = File.createTempFile("share", ".jpg").apply { writeText("x") }
+ val shares = PendingChatShares().apply { offer(PendingChatShare(null, listOf(Uri.fromFile(file)))) }
+ shares.release()
+ assertNull(shares.pending.value)
+ assertTrue(file.exists())
+ file.delete()
+ }
+
+ @Test
+ fun presentingIsClaimedOnce() {
+ val shares = PendingChatShares().apply { offer(share) }
+ assertNotNull(shares.beginPresenting())
+ assertNull(shares.beginPresenting())
+ }
+
+ @Test
+ fun discardDeletesCopiesAndClears() {
+ val file = File.createTempFile("share", ".jpg").apply { writeText("x") }
+ val shares = PendingChatShares().apply { offer(PendingChatShare(null, listOf(Uri.fromFile(file)))) }
+ shares.discard()
+ assertNull(shares.pending.value)
+ assertTrue(!file.exists())
+ }
+
+ @Test
+ fun newShareReplacesAndCleansUpTheOld() {
+ val file = File.createTempFile("share", ".jpg").apply { writeText("x") }
+ val shares = PendingChatShares().apply { offer(PendingChatShare(null, listOf(Uri.fromFile(file)))) }
+ shares.offer(share)
+ assertEquals(share, shares.pending.value)
+ assertTrue(!file.exists())
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ShareRecipientSelectionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ShareRecipientSelectionTest.kt
new file mode 100644
index 000000000..d225b8d52
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ShareRecipientSelectionTest.kt
@@ -0,0 +1,35 @@
+package com.flipcash.shared.chat.share
+
+import com.flipcash.services.models.chat.ChatId
+import org.junit.Test
+import kotlin.test.assertEquals
+
+class ShareRecipientSelectionTest {
+
+ private fun chat(n: Int) = ChatId(ByteArray(32) { n.toByte() })
+
+ @Test
+ fun `picks keep their order and a pick again drops the chat`() {
+ var selection = ShareRecipientSelection()
+ selection = selection.toggle(chat(2)).first
+ selection = selection.toggle(chat(1)).first
+ assertEquals(listOf(chat(2), chat(1)), selection.ids)
+
+ val (after, result) = selection.toggle(chat(2))
+ assertEquals(ShareRecipientSelection.Toggle.Deselected, result)
+ assertEquals(listOf(chat(1)), after.ids)
+ }
+
+ @Test
+ fun `the sixth chat is refused`() {
+ var selection = ShareRecipientSelection()
+ repeat(MAX_SHARE_RECIPIENTS) { selection = selection.toggle(chat(it)).first }
+
+ val (after, result) = selection.toggle(chat(99))
+
+ assertEquals(5, MAX_SHARE_RECIPIENTS)
+ assertEquals(ShareRecipientSelection.Toggle.LimitReached, result)
+ assertEquals(selection, after)
+ assertEquals(ShareRecipientSelection.Toggle.Deselected, after.toggle(chat(0)).second)
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ShareSenderTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ShareSenderTest.kt
new file mode 100644
index 000000000..dc5168dc3
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ShareSenderTest.kt
@@ -0,0 +1,212 @@
+package com.flipcash.shared.chat.share
+
+import android.net.Uri
+import com.flipcash.services.models.chat.ChatId
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class ShareSenderTest {
+
+ private val a = ChatId(ByteArray(32) { 1 })
+ private val b = ChatId(ByteArray(32) { 2 })
+ private val c = ChatId(ByteArray(32) { 3 })
+
+ private fun uris(n: Int) = List(n) { Uri.parse("file:///share/$it.jpg") }
+
+ /** Records every call in order; failures and photo outcomes are scripted per chat. */
+ private class FakeGateway : ShareGateway {
+ val calls = mutableListOf()
+ val failText = mutableSetOf()
+ val failPost = mutableSetOf()
+ /** Outcomes for each message of a chat, consumed attempt by attempt. */
+ val outcomes = mutableMapOf>>()
+ private var chip = 0
+ private val chipChat = mutableMapOf()
+ private val messageIds = mutableMapOf>()
+
+ override suspend fun sendText(chatId: ChatId, text: String): Result {
+ calls += "text:${chatId.tag()}:$text"
+ return if (chatId in failText) Result.failure(RuntimeException("offline")) else Result.success(Unit)
+ }
+
+ override fun stagePhoto(chatId: ChatId, uri: Uri): String {
+ val id = "chip${chip++}"
+ chipChat[id] = chatId
+ calls += "stage:${chatId.tag()}:$id"
+ return id
+ }
+
+ override suspend fun sendPhotos(chatId: ChatId, chipIds: List, caption: String): Result> {
+ calls += "post:${chatId.tag()}:${chipIds.size}:$caption"
+ if (chatId in failPost) return Result.failure(RuntimeException("offline"))
+ val ids = chipIds.map { "m-$it" }
+ messageIds[chatId] = ids
+ return Result.success(ids)
+ }
+
+ override suspend fun awaitPhotos(messageIds: List): Map {
+ val chat = chipChat.entries.first { "m-${it.key}" == messageIds.first() }.value
+ val script = outcomes[chat]?.removeFirstOrNull()
+ return messageIds.withIndex().associate { (i, id) -> id to (script?.getOrNull(i) ?: PhotoOutcome.Sent) }
+ }
+
+ override suspend fun retryPhoto(chatId: ChatId, messageId: String): Result {
+ calls += "retry:${chatId.tag()}:$messageId"
+ return Result.success(Unit)
+ }
+
+ override fun discardPhotos(chipIds: List) {
+ calls += "discard:${chipIds.size}"
+ }
+
+ private fun ChatId.tag() = bytes[0].toInt()
+ }
+
+ private fun sender(gateway: FakeGateway = FakeGateway()) = ShareSender(gateway) to gateway
+
+ @Test
+ fun `text goes to each chat in pick order as a plain message`() = runTest {
+ val (sender, gateway) = sender()
+
+ val done = sender.send(listOf(c, a, b), emptyList(), " hello ")
+
+ assertTrue(done)
+ assertEquals(listOf("text:3:hello", "text:1:hello", "text:2:hello"), gateway.calls)
+ assertTrue(sender.progress.value!!.isComplete)
+ }
+
+ @Test
+ fun `images are staged per chat and the text is the caption`() = runTest {
+ val (sender, gateway) = sender()
+
+ sender.send(listOf(a, b), uris(2), "look")
+
+ // Each chat stages its own copies: a sealed upload is never reused for another chat.
+ assertEquals(
+ listOf(
+ "stage:1:chip0", "stage:1:chip1", "post:1:2:look",
+ "stage:2:chip2", "stage:2:chip3", "post:2:2:look",
+ ),
+ gateway.calls,
+ )
+ }
+
+ @Test
+ fun `nothing to send does nothing`() = runTest {
+ val (sender, gateway) = sender()
+
+ assertFalse(sender.send(listOf(a), emptyList(), " "))
+
+ assertTrue(gateway.calls.isEmpty())
+ assertEquals(null, sender.progress.value)
+ }
+
+ @Test
+ fun `images past the cap are not sent`() = runTest {
+ val (sender, gateway) = sender()
+
+ sender.send(listOf(a), uris(MAX_SHARE_IMAGES + 4), "")
+
+ assertEquals(MAX_SHARE_IMAGES, gateway.calls.count { it.startsWith("stage:") })
+ assertTrue(gateway.calls.contains("post:1:$MAX_SHARE_IMAGES:"))
+ }
+
+ @Test
+ fun `a failed chat does not stop the others and a retry resends only it`() = runTest {
+ val (sender, gateway) = sender()
+ gateway.failText += b
+
+ val first = sender.send(listOf(a, b, c), emptyList(), "hi")
+
+ assertFalse(first)
+ val progress = sender.progress.value!!
+ assertEquals(listOf(b), progress.failed)
+ assertEquals(listOf(b), progress.remaining)
+ assertEquals(ShareChatStatus.Sent, progress.statuses[a])
+ assertEquals(ShareChatStatus.Sent, progress.statuses[c])
+
+ gateway.failText.clear()
+ gateway.calls.clear()
+ val second = sender.send(listOf(a, b, c), emptyList(), "hi")
+
+ assertTrue(second)
+ assertEquals(listOf("text:2:hi"), gateway.calls)
+ }
+
+ @Test
+ fun `a retry resends only the photo messages that failed`() = runTest {
+ val (sender, gateway) = sender()
+ gateway.outcomes[a] = ArrayDeque(listOf(listOf(PhotoOutcome.Sent, PhotoOutcome.Failed(retryable = true))))
+
+ assertFalse(sender.send(listOf(a), uris(2), ""))
+ assertEquals(ShareChatStatus.Failed(ShareFailure.Send, canRetry = true), sender.progress.value!!.statuses[a])
+
+ gateway.calls.clear()
+ assertTrue(sender.send(listOf(a), uris(2), ""))
+
+ // No new staging or posting: only the one message that failed is tried again.
+ assertEquals(listOf("retry:1:m-chip1"), gateway.calls)
+ }
+
+ @Test
+ fun `a rejected image is not retried and leaves the share stuck`() = runTest {
+ val (sender, gateway) = sender()
+ gateway.outcomes[a] = ArrayDeque(listOf(listOf(PhotoOutcome.Failed(retryable = false))))
+
+ sender.send(listOf(a), uris(1), "")
+
+ val progress = sender.progress.value!!
+ assertEquals(ShareChatStatus.Failed(ShareFailure.ImageRejected, canRetry = false), progress.statuses[a])
+ assertTrue(progress.isStuck)
+ }
+
+ @Test
+ fun `a post that fails discards its chips and can be sent again from scratch`() = runTest {
+ val (sender, gateway) = sender()
+ gateway.failPost += a
+
+ assertFalse(sender.send(listOf(a), uris(1), ""))
+ assertTrue(gateway.calls.contains("discard:1"))
+
+ gateway.failPost.clear()
+ assertTrue(sender.send(listOf(a), uris(1), ""))
+ }
+
+ @Test
+ fun `progress is reported chat by chat and cleared when the send ends`() = runTest {
+ val seen = mutableListOf()
+ lateinit var sender: ShareSender
+ val gateway = object : ShareGateway by FakeGateway() {
+ override suspend fun sendText(chatId: ChatId, text: String): Result {
+ seen += sender.sending.value
+ return Result.success(Unit)
+ }
+ }
+ sender = ShareSender(gateway)
+
+ sender.send(listOf(a, b, c), emptyList(), "hi")
+
+ assertEquals(listOf(ShareSending(1, 3), ShareSending(2, 3), ShareSending(3, 3)), seen)
+ assertEquals(null, sender.sending.value)
+ }
+
+ @Test
+ fun `reset forgets what was sent`() = runTest {
+ val (sender, gateway) = sender()
+ sender.send(listOf(a), emptyList(), "hi")
+ sender.reset()
+ gateway.calls.clear()
+
+ sender.send(listOf(a), emptyList(), "hi")
+
+ assertEquals(listOf("text:1:hi"), gateway.calls)
+ }
+}