From 881e4cb32ceb27a564af17fee52adffeeda650a0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:34:20 -0400 Subject: [PATCH 1/6] feat(chat): accept shares from other apps into a chosen conversation Register MainActivity for ACTION_SEND / ACTION_SEND_MULTIPLE (text and images). Images are copied into the capture directory on receipt, a chat picker opens, and the chosen conversation stages the images as photo chips and appends the text to the composer. Nothing is sent. A share is held in PendingChatShares and claimed once by the chat; media is skipped when the chat does not accept it. --- apps/flipcash/app/build.gradle.kts | 1 + .../flipcash/app/src/main/AndroidManifest.xml | 22 +++ .../kotlin/com/flipcash/app/MainActivity.kt | 31 ++++ .../com/flipcash/app/internal/ui/App.kt | 29 +++ .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 10 ++ .../core/src/main/res/values/strings.xml | 1 + .../app/messenger/SharePickerScreen.kt | 113 ++++++++++++ .../app/messenger/internal/ChatViewModel.kt | 56 ++++-- .../internal/SharePickerViewModel.kt | 60 +++++++ .../shared/chat/ui/media/ChatShareIntake.kt | 34 ++++ .../flipcash/shared/chat/share/ChatShare.kt | 170 ++++++++++++++++++ .../shared/chat/share/ChatShareTest.kt | 136 ++++++++++++++ 13 files changed, 653 insertions(+), 12 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/SharePickerScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SharePickerViewModel.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatShareIntake.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ChatShare.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareTest.kt diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index fc4b0d59fc..deb6454069 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 4b69d97ab8..2bd62eb7a4 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 ceabcf1c76..e565fb25ee 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,25 @@ 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 + 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 cff981eea8..95fafce313 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,33 @@ 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 (or the chat itself when + // the share already names one, which is 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 -> { + val share = shares.beginPresenting() ?: return@LaunchedEffect + keyboard.hide() + val target = share.chatId + codeNavigator.navigate( + if (target != null) { + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(target)) + } else { + 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 6cd78e242d..c0bb84fd03 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/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 97d284c755..e976d05507 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 replaced by the chosen + * [Chat]. 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 e6b013469a..becc413941 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1198,6 +1198,7 @@ Invite People Share Recent Chats + Share to Add a message Receive Tips From Everyone Add your name to receive 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 0000000000..19945c566f --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/SharePickerScreen.kt @@ -0,0 +1,113 @@ +package com.flipcash.app.messenger + +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.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +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.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.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.utils.rememberKeyboardController + +/** + * Where a share from another app picks its conversation, behind `AppRoute.Messaging.SharePicker`. + * Choosing replaces this screen with that chat, which stages what was shared in its composer. + * Nothing is sent from here. + */ +@Composable +fun SharePickerScreen() { + val viewModel = hiltViewModel() + val chats by viewModel.chats.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current + val keyboard = rememberKeyboardController() + + Column(modifier = Modifier.fillMaxSize().background(CodeTheme.colors.background).statusBarsPadding()) { + 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.fillMaxSize()) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(chats.orEmpty(), key = { it.chatId.toString() }) { chat -> + ShareTargetRow(chat) { + if (viewModel.choose(chat.chatId)) { + keyboard.hideIfVisible { + navigator.replace( + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)) + ) + } + } else { + navigator.pop() + } + } + } + } + } + } +} + +@Composable +private fun ShareTargetRow(chat: ConversationReference, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.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)) { + Text( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x4), + text = chat.name.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + HorizontalDivider(color = CodeTheme.colors.divider) + } + } +} 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 8513ef3a90..8b8c0532ca 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 @@ -89,6 +89,7 @@ import com.flipcash.shared.chat.ActiveTypist import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftSnapshot import com.flipcash.shared.chat.ChatDraftStore +import com.flipcash.shared.chat.share.PendingChatShares import com.flipcash.shared.chat.ChatHydration import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.FeaturedGroupsStore @@ -262,6 +263,7 @@ internal class ChatViewModel @AssistedInject constructor( @WebPreviewImages val webPreviewImageLoader: ImageLoader? = null, linkCardMemory: LinkCardMemory? = null, featureFlags: FeatureFlagController = NoOpFeatureFlagController, + private val pendingShares: PendingChatShares = PendingChatShares(), ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -1717,6 +1719,7 @@ internal class ChatViewModel @AssistedInject constructor( if (chatId != null) { dispatchEvent(Event.ChatFound(chatId)) restoreDraft(chatId) + applyPendingShare(chatId) chatCoordinator.setActiveChatId(chatId) // A featured group tapped on a profile is usually one the viewer is not in, so // nothing is stored and the profile would sit empty through GetChat. The row @@ -2607,20 +2610,49 @@ internal class ChatViewModel @AssistedInject constructor( .launchIn(viewModelScope) } + private fun stagePhotos(uris: List, captured: Boolean) { + val chatId = stateFlow.value.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() } } + } + + /** + * Puts a share routed to [chatId] into the composer: images as photo chips, the text after + * whatever the composer already holds (the restored draft, so nothing is overwritten). Nothing + * is sent. [PendingChatShares.take] hands the share over once, so a recreated chat finds none. + * + * Called straight after the draft is restored and not by event: the photo handlers start after + * the first frame and an event sent before then has no one listening. + */ + private suspend fun applyPendingShare(chatId: ChatId) { + val share = pendingShares.take(chatId) ?: return + // The same gate as the Photos and Camera rows. A chat that doesn't take media still takes + // the text, and the copies of the images that go unused are deleted here. + if (stateFlow.value.acceptsMedia && share.images.isNotEmpty()) { + stagePhotos(share.images, captured = true) + } else { + share.images.forEach { uri -> uri.path?.let { File(it).delete() } } + } + val text = share.text ?: return + withContext(Dispatchers.Main.immediate) { + val input = stateFlow.value.chatInputState + val existing = input.text.toString() + input.setTextAndPlaceCursorAtEnd( + if (existing.isBlank()) text else existing.trimEnd() + "\n" + text + ) + } + } + 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 0000000000..785cc4f962 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SharePickerViewModel.kt @@ -0,0 +1,60 @@ +package com.flipcash.app.messenger.internal + +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 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.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The chats a share from another app can go to. Lists the Chats tab's own set and, on a pick, + * routes the waiting [PendingChatShares] share to that chat; the chat claims it when it opens. + */ +@HiltViewModel +internal class SharePickerViewModel @Inject constructor( + chatCoordinator: ChatCoordinator, + userManager: UserManager, + resources: ResourceHelper, + private val shares: PendingChatShares, +) : ViewModel() { + + 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() + + private var chosen = false + + init { + viewModelScope.launch { + val selfId = userManager.accountId + _chats.value = chatCoordinator.chatListFeed().first() + .map { it.toConversationReference(selfId, emptyMap(), resources) } + } + } + + /** Routes the share to [chatId]. False when there is no share left to route. */ + fun choose(chatId: ChatId): Boolean { + val routed = shares.route(chatId) + chosen = routed + return routed + } + + override fun onCleared() { + // Left without choosing (back, close): nothing was asked for, so the share is dropped and + // its copied images with it. Cleared rather than on dispose, so a rotation keeps it. + if (!chosen) shares.discard() + } +} 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 0000000000..91c76d5fe6 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatShareIntake.kt @@ -0,0 +1,34 @@ +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 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), + ) + ) + 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 0000000000..04b3b91fca --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ChatShare.kt @@ -0,0 +1,170 @@ +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 stage: [text] for the composer and [images], which are app-owned files under + * the capture directory, safe to read after the sender's grant is gone. + * + * [chatId] is where it goes, null while the user is still choosing. + */ +data class PendingChatShare( + val text: String?, + val images: List, + val chatId: ChatId? = null, + /** 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 to land in a chat. A holder rather than route arguments: the chat route + * lives in the back stack and is saved and restored with it, so anything on it would be staged + * again every time it is restored. Here, [take] removes the share, so it is staged at most once + * whatever recreates the chat screen. + * + * 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() + + /** Holds [share], dropping (and deleting the files of) any share it replaces. */ + fun offer(share: PendingChatShare) { + _pending.getAndUpdateCompat { share }?.deleteFiles() + } + + /** 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 + } + + /** Sends the held share to [chatId]; the chat claims it with [take]. False when none is held. */ + fun route(chatId: ChatId): Boolean { + var routed = false + _pending.update { current -> + routed = current != null + current?.copy(chatId = chatId) + } + return routed + } + + /** + * Removes and returns the share routed to [chatId]. Null for any other chat, and for every + * call after the first. + */ + fun take(chatId: ChatId): PendingChatShare? { + var taken: PendingChatShare? = null + _pending.update { current -> + if (current != null && current.chatId == chatId) { + taken = current + null + } else { + taken = null + current + } + } + return taken + } + + /** Drops the held share and deletes its files: the user backed out, or can't receive it. */ + fun discard() { + _pending.getAndUpdateCompat { null }?.deleteFiles() + } + + 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/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 0000000000..40fe68cfdd --- /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 routedShareIsTakenExactlyOnce() { + val shares = PendingChatShares().apply { offer(share) } + assertTrue(shares.route(chatA)) + assertEquals(share.copy(chatId = chatA), shares.take(chatA)) + assertNull(shares.take(chatA)) + assertNull(shares.pending.value) + } + + @Test + fun otherChatDoesNotTakeIt() { + val shares = PendingChatShares().apply { offer(share) } + shares.route(chatA) + assertNull(shares.take(chatB)) + assertNotNull(shares.pending.value) + } + + @Test + fun unroutedShareIsNotTaken() { + val shares = PendingChatShares().apply { offer(share) } + assertNull(shares.take(chatA)) + } + + @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()) + } +} From fd700a7cacc5949a63398f6e589cd05365a22165 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:46:15 -0400 Subject: [PATCH 2/6] fix(chat): stage shared images in the chat they were routed to The media gate read chatId from state, which ChatFound sets after the share is applied, so images were dropped. Pass the routed chat id instead. --- .../app/messenger/internal/ChatViewModel.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 8b8c0532ca..a69df09e73 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 @@ -2610,8 +2610,12 @@ internal class ChatViewModel @AssistedInject constructor( .launchIn(viewModelScope) } - private fun stagePhotos(uris: List, captured: Boolean) { - val chatId = stateFlow.value.chatId ?: return + 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 -> @@ -2635,8 +2639,10 @@ internal class ChatViewModel @AssistedInject constructor( val share = pendingShares.take(chatId) ?: return // The same gate as the Photos and Camera rows. A chat that doesn't take media still takes // the text, and the copies of the images that go unused are deleted here. - if (stateFlow.value.acceptsMedia && share.images.isNotEmpty()) { - stagePhotos(share.images, captured = true) + // The chat id is passed in because ChatFound, which sets it in state, is reduced after this + // runs; acceptsMedia would read false for a chat that does take media. + if (stateFlow.value.editing == null && share.images.isNotEmpty()) { + stagePhotos(share.images, captured = true, chatId = chatId) } else { share.images.forEach { uri -> uri.path?.let { File(it).delete() } } } From 32509975fb810e384eef67d9aea18038da7bdf6d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:50:19 -0400 Subject: [PATCH 3/6] fix(chat): hold the share picker until its list is known and keep rows in place The picker drew an empty list before the feed emitted and took only the first emission. It now shows a progress indicator until the list is known, keeps following the feed, and merges later lists into the order already on screen so rows do not move under a tap. --- .../app/messenger/SharePickerScreen.kt | 14 ++++++++++- .../internal/SharePickerViewModel.kt | 22 ++++++++++++++--- .../internal/SharePickerMergeTest.kt | 24 +++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SharePickerMergeTest.kt 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 index 19945c566f..08650b5c15 100644 --- 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 @@ -38,6 +38,7 @@ import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.utils.rememberKeyboardController /** @@ -61,8 +62,19 @@ fun SharePickerScreen() { endContent = { AppBarDefaults.Close(onClick = { navigator.pop() }) }, ) Box(modifier = Modifier.fillMaxSize()) { + val loaded = chats + if (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), + ) + return@Box + } LazyColumn(modifier = Modifier.fillMaxSize()) { - items(chats.orEmpty(), key = { it.chatId.toString() }) { chat -> + items(loaded, key = { it.chatId.toString() }) { chat -> ShareTargetRow(chat) { if (viewModel.choose(chat.chatId)) { keyboard.hideIfVisible { 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 index 785cc4f962..683209e477 100644 --- 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 @@ -14,7 +14,6 @@ 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.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -40,8 +39,12 @@ internal class SharePickerViewModel @Inject constructor( init { viewModelScope.launch { val selfId = userManager.accountId - _chats.value = chatCoordinator.chatListFeed().first() - .map { it.toConversationReference(selfId, emptyMap(), resources) } + 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. + _chats.value = fresh.mergeStable(_chats.value) { it.chatId } + } } } @@ -58,3 +61,16 @@ internal class SharePickerViewModel @Inject constructor( if (!chosen) 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 } +} 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 0000000000..7aad7de67f --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SharePickerMergeTest.kt @@ -0,0 +1,24 @@ +package com.flipcash.app.messenger.internal + +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"))) + } +} From 686313e31c8de6aeb7fe6c252b382a65609f610d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 11 Oct 2026 21:29:51 -0400 Subject: [PATCH 4/6] fix(chat): open a share launch directly on the picker The launch route was chosen before the shared images finished copying, so the chat list rendered first and the picker was pushed over it. The share is now flagged as incoming when the intent arrives and the launch route waits for it (bounded at 3s), then places the picker straight over the start screen. --- .../kotlin/com/flipcash/app/MainActivity.kt | 2 + .../app/internal/ui/navigation/MainRoot.kt | 42 ++++++++++++++++++- .../navigation/BuildNavGraphForLaunchTest.kt | 12 ++++++ .../shared/chat/ui/media/ChatShareIntake.kt | 12 ++++++ .../flipcash/shared/chat/share/ChatShare.kt | 35 ++++++++++++++++ .../shared/chat/share/ChatShareTest.kt | 27 ++++++++++++ 6 files changed, 128 insertions(+), 2 deletions(-) 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 e565fb25ee..674c1b5b53 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 @@ -228,6 +228,8 @@ class MainActivity : FragmentActivity() { 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) } 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 3eec0c76d3..f2dc0358f5 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,34 @@ 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 } } + val share = shares.beginPresenting() ?: return null + val target = share.chatId + return if (target != null) { + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(target)) + } else { + 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 +254,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 dc056923be..522c58d98a 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/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 index 91c76d5fe6..36099c489b 100644 --- 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 @@ -15,6 +15,18 @@ import com.flipcash.shared.chat.share.toChatShareRequest * 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 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 index 04b3b91fca..1d0a74da61 100644 --- 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 @@ -105,9 +105,30 @@ 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. */ @@ -134,6 +155,19 @@ class PendingChatShares @Inject constructor() { return routed } + /** + * [route] with the message the user ended up with in the picker, which replaces the shared text. + * A blank [text] clears it. + */ + fun route(chatId: ChatId, text: String?): Boolean { + var routed = false + _pending.update { current -> + routed = current != null + current?.copy(chatId = chatId, text = text?.trim()?.takeIf { it.isNotEmpty() }) + } + return routed + } + /** * Removes and returns the share routed to [chatId]. Null for any other chat, and for every * call after the first. @@ -155,6 +189,7 @@ class PendingChatShares @Inject constructor() { /** 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( 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 index 40fe68cfdd..ff6d93fd61 100644 --- 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 @@ -95,6 +95,33 @@ class ChatShareTest { assertNull(shares.pending.value) } + @Test + fun routingWithTextReplacesTheSharedText() { + val shares = PendingChatShares().apply { offer(share) } + assertTrue(shares.route(chatA, " edited ")) + assertEquals("edited", shares.take(chatA)?.text) + } + + @Test + fun routingWithBlankTextClearsIt() { + val shares = PendingChatShares().apply { offer(share) } + shares.route(chatA, " ") + assertNull(shares.take(chatA)?.text) + } + + @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 otherChatDoesNotTakeIt() { val shares = PendingChatShares().apply { offer(share) } From 1d8d20468781e3bcbfbb6c88126632637d5eaf0f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 11 Oct 2026 21:29:52 -0400 Subject: [PATCH 5/6] feat(chat): match the share picker to the iOS Send to screen --- .../core/src/main/res/values/strings.xml | 8 +- .../app/messenger/SharePickerScreen.kt | 294 +++++++++++++++--- .../internal/SharePickerViewModel.kt | 50 ++- .../internal/SharePickerMergeTest.kt | 21 ++ 4 files changed, 329 insertions(+), 44 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index becc413941..36577747e8 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1198,7 +1198,13 @@ Invite People Share Recent Chats - Share to + Send to + Chats + To %1$s + No chats match \u201C%1$s\u201D + No chats yet + Start a chat in Flipcash, then share again. + %1$d attached Add a message Receive Tips From Everyone Add your name to receive 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 index 08650b5c15..1d7887968a 100644 --- 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 @@ -1,5 +1,11 @@ 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 @@ -7,53 +13,89 @@ 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.clickable -import androidx.compose.foundation.background 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.runtime.Composable import androidx.compose.runtime.getValue -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle +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 conversation, behind `AppRoute.Messaging.SharePicker`. - * Choosing replaces this screen with that chat, which stages what was shared in its composer. - * Nothing is sent from here. + * The iOS share extension's "Send to" screen: a title with a close button, a search field, the + * chats as a radio list, and, once one is picked, a bar with what is being shared, a message field + * and the action. + * + * Differs from iOS where the app does: one chat at a time, and the action opens that chat with the + * share staged in its composer rather than sending it from here, so it reads "Next", not "Send". + * Nothing is sent from this screen. */ @Composable fun SharePickerScreen() { val viewModel = hiltViewModel() - val chats by viewModel.chats.collectAsStateWithLifecycle() + val allChats by viewModel.chats.collectAsStateWithLifecycle() + val chats by viewModel.visibleChats.collectAsStateWithLifecycle() + val selected by viewModel.selected.collectAsStateWithLifecycle() + val share by viewModel.share.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current val keyboard = rememberKeyboardController() - Column(modifier = Modifier.fillMaxSize().background(CodeTheme.colors.background).statusBarsPadding()) { + // The message starts as the text that was shared, as it does on iOS, and is what goes on. + val message = rememberTextFieldState(remember(viewModel) { viewModel.share.value?.text.orEmpty() }) + val query = viewModel.queryState.text.toString().trim() + + Column( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.background) + .statusBarsPadding() + .imePadding(), + ) { AppBarWithTitle( modifier = Modifier.fillMaxWidth(), title = stringResource(R.string.title_shareToChat), @@ -61,46 +103,131 @@ fun SharePickerScreen() { contentPadding = PaddingValues(16.dp), endContent = { AppBarDefaults.Close(onClick = { navigator.pop() }) }, ) - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { val loaded = chats - if (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), + 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), ) - return@Box - } - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(loaded, key = { it.chatId.toString() }) { chat -> - ShareTargetRow(chat) { - if (viewModel.choose(chat.chatId)) { - keyboard.hideIfVisible { - navigator.replace( - AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)) - ) - } - } else { - navigator.pop() + else -> 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 (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 { + item(key = "header") { + SectionHeader( + stringResource(if (query.isEmpty()) R.string.label_recentChats else R.string.label_chats) + ) + } + items(loaded, key = { it.chatId.toString() }) { chat -> + ShareTargetRow( + chat = chat, + selected = chat.chatId == selected, + onClick = { viewModel.toggle(chat.chatId) }, + ) } } } } } + + // Slides up once a chat is picked, as the iOS compose bar does. + val picked = chats.orEmpty().firstOrNull { it.chatId == selected } + AnimatedVisibility( + visible = picked != null && share != null, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + ComposeBar( + recipient = picked?.name.orEmpty(), + images = share?.images.orEmpty().map { it.toString() }, + messageState = message, + onNext = { + val target = selected + if (target != null && viewModel.confirm(message.text.toString())) { + keyboard.hideIfVisible { + navigator.replace(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(target))) + } + } else { + navigator.pop() + } + }, + ) + } } } @Composable -private fun ShareTargetRow(chat: ConversationReference, onClick: () -> Unit) { +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() - .clickable(role = Role.Button, onClick = onClick) - .padding(horizontal = 20.dp), + .toggleable(value = selected, role = Role.RadioButton, onValueChange = { onClick() }) + .padding(horizontal = ListInset), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { ContactAvatar( image = chat.image, @@ -111,15 +238,102 @@ private fun ShareTargetRow(chat: ConversationReference, onClick: () -> Unit) { .clip(CircleShape), ) Column(modifier = Modifier.weight(1f)) { - Text( + Row( modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x4), - text = chat.name.orEmpty(), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + 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) } } } + +/** + * Who it goes to, then the field, with the shared images stacked in it, and the action. The field + * is the chat composer's own so typing here feels the same. + */ +@Composable +private fun ComposeBar( + recipient: String, + images: List, + messageState: androidx.compose.foundation.text.input.TextFieldState, + onNext: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(CodeTheme.colors.surface) + .navigationBarsPadding() + .padding(horizontal = ListInset) + .padding(top = 12.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChatInput( + hint = stringResource(R.string.hint_addAMessage), + state = messageState, + hasAttachments = images.isNotEmpty(), + submit = ChatInputSubmit.Action(label = stringResource(com.flipcash.core.R.string.action_next), perform = onNext), + header = if (images.isEmpty()) null else ({ ThumbnailStack(images) }), + ) + Text( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + text = stringResource(R.string.label_shareTo, recipient), + style = CodeTheme.typography.textSmall, + color = White50, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** 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/SharePickerViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SharePickerViewModel.kt index 683209e477..5af6a07429 100644 --- 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 @@ -1,11 +1,14 @@ 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 com.flipcash.shared.chat.share.PendingChatShare import com.flipcash.shared.chat.share.PendingChatShares import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.toConversationReference @@ -14,6 +17,10 @@ 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.launch import javax.inject.Inject @@ -34,6 +41,24 @@ internal class SharePickerViewModel @Inject constructor( /** 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, for the compose bar's preview and message. Null once it is gone. */ + val share: StateFlow = shares.pending + + private val _selected = MutableStateFlow(null) + + /** The chat the share will go to, or null while none is picked. */ + val selected: StateFlow = _selected.asStateFlow() + private var chosen = false init { @@ -48,9 +73,18 @@ internal class SharePickerViewModel @Inject constructor( } } - /** Routes the share to [chatId]. False when there is no share left to route. */ - fun choose(chatId: ChatId): Boolean { - val routed = shares.route(chatId) + /** Picks [chatId], or clears the pick when it is already the one picked. */ + fun toggle(chatId: ChatId) { + _selected.update { if (it == chatId) null else chatId } + } + + /** + * Routes the share to the picked chat with [message] as its text, replacing what was shared. + * False when nothing is picked or there is no share left to route. + */ + fun confirm(message: String): Boolean { + val chatId = _selected.value ?: return false + val routed = shares.route(chatId, message) chosen = routed return routed } @@ -74,3 +108,13 @@ internal fun List.mergeStable(previous: List?, key: (T) -> K): List 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 index 7aad7de67f..edc77dc036 100644 --- 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 @@ -1,5 +1,9 @@ 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 @@ -21,4 +25,21 @@ class SharePickerMergeTest { 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")) + } } From 8c9fa8ad83977d638062ddae97d316618b1cd601 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 11 Oct 2026 21:45:55 -0400 Subject: [PATCH 6/6] feat(share): send from the share picker to several chats The picker now sends directly, as the iOS share extension does: up to 5 chats, up to 10 images, the message as the caption of the last photo, progress per chat, and a retry that goes only to the chats that failed. Sends reuse the chat screen's own text and photo paths, so OutgoingEncryption decides sealing per chat. The composer staging path is removed. --- .../com/flipcash/app/internal/ui/App.kt | 15 +- .../app/internal/ui/navigation/MainRoot.kt | 9 +- .../kotlin/com/flipcash/app/core/AppRoute.kt | 4 +- .../core/src/main/res/values/strings.xml | 10 +- .../app/messenger/SharePickerScreen.kt | 199 ++++++++++----- .../app/messenger/internal/ChatViewModel.kt | 32 --- .../internal/SharePickerViewModel.kt | 113 +++++++-- .../shared/chat/ui/media/ChatShareIntake.kt | 1 + .../flipcash/shared/chat/share/ChatShare.kt | 55 +---- .../shared/chat/share/ChatShareGateway.kt | 46 ++++ .../chat/share/ShareRecipientSelection.kt | 18 ++ .../flipcash/shared/chat/share/ShareSender.kt | 206 ++++++++++++++++ .../shared/chat/share/ChatShareGatewayTest.kt | 230 ++++++++++++++++++ .../shared/chat/share/ChatShareTest.kt | 41 +--- .../chat/share/ShareRecipientSelectionTest.kt | 35 +++ .../shared/chat/share/ShareSenderTest.kt | 212 ++++++++++++++++ 16 files changed, 1010 insertions(+), 216 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ChatShareGateway.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ShareRecipientSelection.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/share/ShareSender.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ChatShareGatewayTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ShareRecipientSelectionTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/share/ShareSenderTest.kt 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 95fafce313..c8c771f0f2 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 @@ -326,8 +326,8 @@ internal fun App( } // A share from another app: once the app is past its cold-start Loading - // route and signed in, open the chat picker (or the chat itself when - // the share already names one, which is where direct share lands). + // 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 @@ -336,16 +336,9 @@ internal fun App( if (pendingShare == null || currentRoute is AppRoute.Loading) return@LaunchedEffect when (userState.authState) { AuthState.Ready -> { - val share = shares.beginPresenting() ?: return@LaunchedEffect + shares.beginPresenting() ?: return@LaunchedEffect keyboard.hide() - val target = share.chatId - codeNavigator.navigate( - if (target != null) { - AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(target)) - } else { - AppRoute.Messaging.SharePicker - } - ) + codeNavigator.navigate(AppRoute.Messaging.SharePicker) } is AuthState.Onboarding, AuthState.LoggedOut -> shares.discard() else -> Unit 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 f2dc0358f5..b36b515b45 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 @@ -203,13 +203,8 @@ private fun List.startsWith(prefix: List): Boolean { 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 } } - val share = shares.beginPresenting() ?: return null - val target = share.chatId - return if (target != null) { - AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(target)) - } else { - AppRoute.Messaging.SharePicker - } + shares.beginPresenting() ?: return null + return AppRoute.Messaging.SharePicker } private val ShareArrivalTimeout = 3.seconds 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 e976d05507..4c351dcc6c 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 @@ -471,8 +471,8 @@ sealed interface AppRoute : NavKey, Parcelable { /** * 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 replaced by the chosen - * [Chat]. Carries nothing: what was shared is held there, not on a route a back stack + * 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 diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 36577747e8..667da3672b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1200,12 +1200,18 @@ Recent Chats Send to Chats - To %1$s + 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 + 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 index 1d7887968a..976fc33194 100644 --- 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 @@ -28,7 +28,16 @@ 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 @@ -66,14 +75,16 @@ import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.utils.rememberKeyboardController /** - * Where a share from another app picks its conversation, behind `AppRoute.Messaging.SharePicker`. - * The iOS share extension's "Send to" screen: a title with a close button, a search field, the - * chats as a radio list, and, once one is picked, a bar with what is being shared, a message field - * and the action. + * 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 the app does: one chat at a time, and the action opens that chat with the - * share staged in its composer rather than sending it from here, so it reads "Next", not "Send". - * Nothing is sent from this screen. + * 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() { @@ -81,14 +92,21 @@ fun SharePickerScreen() { val allChats by viewModel.chats.collectAsStateWithLifecycle() val chats by viewModel.visibleChats.collectAsStateWithLifecycle() val selected by viewModel.selected.collectAsStateWithLifecycle() - val share by viewModel.share.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 keyboard = rememberKeyboardController() - - // The message starts as the text that was shared, as it does on iOS, and is what goes on. - val message = rememberTextFieldState(remember(viewModel) { viewModel.share.value?.text.orEmpty() }) + 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() @@ -119,38 +137,47 @@ fun SharePickerScreen() { title = stringResource(R.string.title_shareNoChats), detail = stringResource(R.string.subtitle_shareNoChats), ) - else -> 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 (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 -> { + // 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), ) } - } else { - item(key = "header") { - SectionHeader( - stringResource(if (query.isEmpty()) R.string.label_recentChats else R.string.label_chats) - ) + 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) } + } } - items(loaded, key = { it.chatId.toString() }) { chat -> - ShareTargetRow( - chat = chat, - selected = chat.chatId == selected, - onClick = { viewModel.toggle(chat.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) } + } } } } @@ -158,26 +185,24 @@ fun SharePickerScreen() { } // Slides up once a chat is picked, as the iOS compose bar does. - val picked = chats.orEmpty().firstOrNull { it.chatId == selected } + val names = allChats.orEmpty().associate { it.chatId to it.name.orEmpty() } AnimatedVisibility( - visible = picked != null && share != null, + visible = selected.isNotEmpty(), enter = slideInVertically { it }, exit = slideOutVertically { it }, ) { + val isSending = sending != null + val hasMessage = viewModel.messageState.text.isNotBlank() ComposeBar( - recipient = picked?.name.orEmpty(), - images = share?.images.orEmpty().map { it.toString() }, - messageState = message, - onNext = { - val target = selected - if (target != null && viewModel.confirm(message.text.toString())) { - keyboard.hideIfVisible { - navigator.replace(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(target))) - } - } else { - navigator.pop() - } - }, + 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() }, ) } } @@ -261,16 +286,23 @@ private fun ShareTargetRow(chat: ConversationReference, selected: Boolean, onCli } /** - * Who it goes to, then the field, with the shared images stacked in it, and the action. The field - * is the chat composer's own so typing here feels the same. + * 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( - recipient: String, + recipients: String, images: List, - messageState: androidx.compose.foundation.text.input.TextFieldState, - onNext: () -> Unit, + 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() @@ -280,16 +312,41 @@ private fun ComposeBar( .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(), - submit = ChatInputSubmit.Action(label = stringResource(com.flipcash.core.R.string.action_next), perform = onNext), + 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 = stringResource(R.string.label_shareTo, recipient), + text = recipients, style = CodeTheme.typography.textSmall, color = White50, maxLines = 1, @@ -298,6 +355,22 @@ private fun ComposeBar( } } +@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) { 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 a69df09e73..ba59cf28cf 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 @@ -89,7 +89,6 @@ import com.flipcash.shared.chat.ActiveTypist import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftSnapshot import com.flipcash.shared.chat.ChatDraftStore -import com.flipcash.shared.chat.share.PendingChatShares import com.flipcash.shared.chat.ChatHydration import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.FeaturedGroupsStore @@ -263,7 +262,6 @@ internal class ChatViewModel @AssistedInject constructor( @WebPreviewImages val webPreviewImageLoader: ImageLoader? = null, linkCardMemory: LinkCardMemory? = null, featureFlags: FeatureFlagController = NoOpFeatureFlagController, - private val pendingShares: PendingChatShares = PendingChatShares(), ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -1719,7 +1717,6 @@ internal class ChatViewModel @AssistedInject constructor( if (chatId != null) { dispatchEvent(Event.ChatFound(chatId)) restoreDraft(chatId) - applyPendingShare(chatId) chatCoordinator.setActiveChatId(chatId) // A featured group tapped on a profile is usually one the viewer is not in, so // nothing is stored and the profile would sit empty through GetChat. The row @@ -2627,35 +2624,6 @@ internal class ChatViewModel @AssistedInject constructor( if (captured) uris.drop(room.coerceAtLeast(0)).forEach { it.path?.let { path -> File(path).delete() } } } - /** - * Puts a share routed to [chatId] into the composer: images as photo chips, the text after - * whatever the composer already holds (the restored draft, so nothing is overwritten). Nothing - * is sent. [PendingChatShares.take] hands the share over once, so a recreated chat finds none. - * - * Called straight after the draft is restored and not by event: the photo handlers start after - * the first frame and an event sent before then has no one listening. - */ - private suspend fun applyPendingShare(chatId: ChatId) { - val share = pendingShares.take(chatId) ?: return - // The same gate as the Photos and Camera rows. A chat that doesn't take media still takes - // the text, and the copies of the images that go unused are deleted here. - // The chat id is passed in because ChatFound, which sets it in state, is reduced after this - // runs; acceptsMedia would read false for a chat that does take media. - if (stateFlow.value.editing == null && share.images.isNotEmpty()) { - stagePhotos(share.images, captured = true, chatId = chatId) - } else { - share.images.forEach { uri -> uri.path?.let { File(it).delete() } } - } - val text = share.text ?: return - withContext(Dispatchers.Main.immediate) { - val input = stateFlow.value.chatInputState - val existing = input.text.toString() - input.setTextAndPlaceCursorAtEnd( - if (existing.isBlank()) text else existing.trimEnd() + "\n" + text - ) - } - } - private fun initPhotoHandlers() { eventFlow.filterIsInstance() .onEach { event -> stagePhotos(event.uris, event.captured) } 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 index 5af6a07429..bbbaf16d6b 100644 --- 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 @@ -8,7 +8,14 @@ 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 @@ -21,12 +28,14 @@ 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. Lists the Chats tab's own set and, on a pick, - * routes the waiting [PendingChatShares] share to that chat; the chat claims it when it opens. + * 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( @@ -34,8 +43,11 @@ internal class SharePickerViewModel @Inject constructor( 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. */ @@ -51,15 +63,45 @@ internal class SharePickerViewModel @Inject constructor( combine(_chats, query) { all, q -> all?.filterByQuery(q) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - /** The share being sent, for the compose bar's preview and message. Null once it is gone. */ - val share: StateFlow = shares.pending + /** 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 _selected = MutableStateFlow(null) + private val _done = MutableStateFlow(false) - /** The chat the share will go to, or null while none is picked. */ - val selected: StateFlow = _selected.asStateFlow() + /** True once every chat has the share, so the screen can close. */ + val done: StateFlow = _done.asStateFlow() - private var chosen = false + 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 { @@ -68,31 +110,58 @@ internal class SharePickerViewModel @Inject constructor( 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) } } } - /** Picks [chatId], or clears the pick when it is already the one picked. */ + /** 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) { - _selected.update { if (it == chatId) null else 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() } - /** - * Routes the share to the picked chat with [message] as its text, replacing what was shared. - * False when nothing is picked or there is no share left to route. - */ - fun confirm(message: String): Boolean { - val chatId = _selected.value ?: return false - val routed = shares.route(chatId, message) - chosen = routed - return routed + /** 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 choosing (back, close): nothing was asked for, so the share is dropped and - // its copied images with it. Cleared rather than on dispose, so a rotation keeps it. - if (!chosen) shares.discard() + // 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() } } 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 index 36099c489b..824fd6c11c 100644 --- 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 @@ -40,6 +40,7 @@ private suspend fun stageChatShare(context: Context, intent: Intent, shares: Pen 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 index 1d0a74da61..141e48a70f 100644 --- 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 @@ -74,15 +74,17 @@ fun Intent.toChatShareRequest(mimeOf: (Uri) -> String?): ChatShareRequest? { fun chatIdFromShortcutId(shortcutId: String?): ChatId? = null /** - * A share ready to stage: [text] for the composer and [images], which are app-owned files under + * 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 where it goes, null while the user is still choosing. + * [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, ) { @@ -92,10 +94,9 @@ data class PendingChatShare( } /** - * The one share waiting to land in a chat. A holder rather than route arguments: the chat route - * lives in the back stack and is saved and restored with it, so anything on it would be staged - * again every time it is restored. Here, [take] removes the share, so it is staged at most once - * whatever recreates the chat screen. + * 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. @@ -145,45 +146,13 @@ class PendingChatShares @Inject constructor() { return claimed } - /** Sends the held share to [chatId]; the chat claims it with [take]. False when none is held. */ - fun route(chatId: ChatId): Boolean { - var routed = false - _pending.update { current -> - routed = current != null - current?.copy(chatId = chatId) - } - return routed - } - - /** - * [route] with the message the user ended up with in the picker, which replaces the shared text. - * A blank [text] clears it. - */ - fun route(chatId: ChatId, text: String?): Boolean { - var routed = false - _pending.update { current -> - routed = current != null - current?.copy(chatId = chatId, text = text?.trim()?.takeIf { it.isNotEmpty() }) - } - return routed - } - /** - * Removes and returns the share routed to [chatId]. Null for any other chat, and for every - * call after the first. + * 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 take(chatId: ChatId): PendingChatShare? { - var taken: PendingChatShare? = null - _pending.update { current -> - if (current != null && current.chatId == chatId) { - taken = current - null - } else { - taken = null - current - } - } - return taken + 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. */ 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 0000000000..eccba83981 --- /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 0000000000..0ed18e6b54 --- /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 0000000000..d545e7ccd6 --- /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 0000000000..6727aabc94 --- /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 index ff6d93fd61..c651b13eeb 100644 --- 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 @@ -86,29 +86,6 @@ class ChatShareTest { private val chatB = ChatId(UUID.fromString("0189d5f1-3c6a-7b4e-8f21-9c3d4e5f6a7c").bytes) private val share = PendingChatShare("hi", emptyList()) - @Test - fun routedShareIsTakenExactlyOnce() { - val shares = PendingChatShares().apply { offer(share) } - assertTrue(shares.route(chatA)) - assertEquals(share.copy(chatId = chatA), shares.take(chatA)) - assertNull(shares.take(chatA)) - assertNull(shares.pending.value) - } - - @Test - fun routingWithTextReplacesTheSharedText() { - val shares = PendingChatShares().apply { offer(share) } - assertTrue(shares.route(chatA, " edited ")) - assertEquals("edited", shares.take(chatA)?.text) - } - - @Test - fun routingWithBlankTextClearsIt() { - val shares = PendingChatShares().apply { offer(share) } - shares.route(chatA, " ") - assertNull(shares.take(chatA)?.text) - } - @Test fun incomingHoldsFromExpectUntilOfferedOrCancelled() { val shares = PendingChatShares() @@ -123,17 +100,13 @@ class ChatShareTest { } @Test - fun otherChatDoesNotTakeIt() { - val shares = PendingChatShares().apply { offer(share) } - shares.route(chatA) - assertNull(shares.take(chatB)) - assertNotNull(shares.pending.value) - } - - @Test - fun unroutedShareIsNotTaken() { - val shares = PendingChatShares().apply { offer(share) } - assertNull(shares.take(chatA)) + 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 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 0000000000..d225b8d52d --- /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 0000000000..dc5168dc3e --- /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) + } +}