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