From 6034fb8bcc34e97a4386f63f4d249a4cf579fbda Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 11:39:54 -0400 Subject: [PATCH 1/6] feat(chat): add E2eePolicy as the single reader of use_e2ee --- .../com/flipcash/services/chat/E2eePolicy.kt | 34 ++++++++++ .../flipcash/services/chat/E2eePolicyTest.kt | 62 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/chat/E2eePolicyTest.kt diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt new file mode 100644 index 0000000000..218cf0f4ff --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt @@ -0,0 +1,34 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.getcode.opencode.model.core.ID + +/** + * The @flipcash account's user id. Chats with it stay plaintext, because the backend sends its + * onboarding messages in the clear and reads the user's replies. + * + * Null until the id is known, which makes the exemption a no-op: every DM with the flag on is + * treated as encrypted. + */ +// TODO: set @flipcash user id +val FLIPCASH_ACCOUNT_ID: ID? = null + +/** + * Whether new content in a chat should be end-to-end encrypted. The client decides; the server's + * `use_e2ee` flag is only an input to that decision while E2EE is rolling out. + * + * This is the only reader of [ChatMetadata.useE2ee]. Screens follow the policy or the transcript, + * so the flag can be dropped later by changing [shouldEncrypt] alone. + * + * @param flipcashAccountId the @flipcash account to exempt; injectable for tests. + */ +class E2eePolicy(private val flipcashAccountId: ID? = FLIPCASH_ACCOUNT_ID) { + + fun shouldEncrypt(chat: ChatMetadata): Boolean { + val isDm = chat.type == ChatType.CONTACT_DM || chat.type == ChatType.TIP_DM + if (!isDm || !chat.useE2ee) return false + val official = flipcashAccountId ?: return true + return chat.members.none { it.userId == official } + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/E2eePolicyTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/E2eePolicyTest.kt new file mode 100644 index 0000000000..8219af6d39 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/E2eePolicyTest.kt @@ -0,0 +1,62 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +class E2eePolicyTest { + + private val flipcash = List(32) { 1.toByte() } + private val friend = List(32) { 2.toByte() } + private val policy = E2eePolicy(flipcashAccountId = flipcash) + + private fun member(id: List) = + ChatMember(userId = id, userProfile = UserProfile.Empty, pointers = emptyList()) + + private fun chat( + type: ChatType, + useE2ee: Boolean, + with: List = friend, + ) = ChatMetadata( + chatId = ChatId(ByteArray(32)), + type = type, + members = listOf(member(with)), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(0), + useE2ee = useE2ee, + ) + + @Test + fun `a DM with the flag on is encrypted`() { + assertTrue(policy.shouldEncrypt(chat(ChatType.CONTACT_DM, useE2ee = true))) + assertTrue(policy.shouldEncrypt(chat(ChatType.TIP_DM, useE2ee = true))) + } + + @Test + fun `a DM with the flag off is not encrypted`() { + assertFalse(policy.shouldEncrypt(chat(ChatType.CONTACT_DM, useE2ee = false))) + assertFalse(policy.shouldEncrypt(chat(ChatType.TIP_DM, useE2ee = false))) + } + + @Test + fun `a group is never encrypted`() { + assertFalse(policy.shouldEncrypt(chat(ChatType.GROUP, useE2ee = true))) + } + + @Test + fun `a chat with the flipcash account is never encrypted`() { + assertFalse(policy.shouldEncrypt(chat(ChatType.TIP_DM, useE2ee = true, with = flipcash))) + } + + @Test + fun `the exemption is a no-op until the flipcash id is set`() { + val unset = E2eePolicy(flipcashAccountId = null) + assertTrue(unset.shouldEncrypt(chat(ChatType.TIP_DM, useE2ee = true, with = flipcash))) + } +} From 37e149e3db75f476cee7d94de8fd4094ac6c64ac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 11:56:23 -0400 Subject: [PATCH 2/6] refactor(ui): move BulletRow into ui:components The E2EE learn-more sheet lists its items with the same icon-and-line row the contacts rationale uses. Moved as-is, with a spacing parameter for the sheet's 12dp gap; existing callers keep grid.x2. --- .../ContactPermissionScreenContent.kt | 1 + .../ContactRationalePermissionContent.kt | 24 +---------- .../com/getcode/ui/components/BulletRow.kt | 43 +++++++++++++++++++ 3 files changed, 45 insertions(+), 23 deletions(-) create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/BulletRow.kt diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt index c888ca0198..b2111f10e0 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt @@ -27,6 +27,7 @@ import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.shared.permissions.R import com.flipcash.app.analytics.LocalAnalytics import com.getcode.theme.CodeTheme +import com.getcode.ui.components.BulletRow import com.getcode.ui.theme.CodeScaffold import com.getcode.util.permissions.ProvideTestPermissions import com.getcode.util.permissions.rememberContactPermission diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactRationalePermissionContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactRationalePermissionContent.kt index 2258c0de7e..c4c04f1a14 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactRationalePermissionContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactRationalePermissionContent.kt @@ -33,6 +33,7 @@ import com.flipcash.shared.chat.ui.AnimatedConversationPreview import com.getcode.opencode.compose.ExchangeStub import com.getcode.opencode.compose.LocalExchange import com.getcode.theme.CodeTheme +import com.getcode.ui.components.BulletRow import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold @@ -165,29 +166,6 @@ private fun SimplifiedContactRationaleContent() { } } -@Composable -internal fun BulletRow( - painter: Painter, - text: String, - modifier : Modifier = Modifier, - textStyle: TextStyle = CodeTheme.typography.textSmall, - textColor: Color = CodeTheme.colors.textSecondary, - iconTint: Color = CodeTheme.colors.textMain, -) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(painter = painter, contentDescription = null, tint = iconTint) - Text( - text = text, - style = textStyle, - color = textColor, - ) - } -} - @Composable @Preview @PreviewWrapper(FlipcashThemeWrapper::class) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/BulletRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/BulletRow.kt new file mode 100644 index 0000000000..d08b2bc6f7 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/BulletRow.kt @@ -0,0 +1,43 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import com.getcode.theme.CodeTheme + +/** + * An icon followed by a line of text, for lists of short statements. + * + * Pass [Color.Unspecified] as [iconTint] for an icon that carries its own colours. + */ +@Composable +fun BulletRow( + painter: Painter, + text: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = CodeTheme.typography.textSmall, + textColor: Color = CodeTheme.colors.textSecondary, + iconTint: Color = CodeTheme.colors.textMain, + spacing: Dp = CodeTheme.dimens.grid.x2, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(painter = painter, contentDescription = null, tint = iconTint) + Text( + text = text, + style = textStyle, + color = textColor, + ) + } +} From 95fb596caa0b03dca72b8f2effcf87542852fef6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 11:56:57 -0400 Subject: [PATCH 3/6] feat(chat): show end-to-end encryption status on profiles DM profiles get a footer saying messages are end-to-end encrypted when E2eePolicy says the chat is; Group Info always says group chats aren't. Learn More opens a sheet listing what is and isn't encrypted, one for DMs (node 10416:1533) and one for groups (node 10557:1412). The footer is the scaffold's bottom bar rather than MenuList's footer, so it stays at the bottom while the rows scroll. ChatViewModel maps the chat's metadata through E2eePolicy, which stays the only reader of use_e2ee; the policy gains an @Inject constructor for that. --- .../ui/navigation/AppScreenContent.kt | 3 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 19 ++ .../core/src/main/res/values/strings.xml | 20 ++ .../app/messenger/E2eeLearnMoreScreen.kt | 19 ++ .../app/messenger/internal/ChatViewModel.kt | 18 ++ .../internal/screens/E2eeLearnMoreSheet.kt | 199 ++++++++++++++++++ .../screens/profile/ChatProfileScreen.kt | 14 +- .../internal/screens/profile/E2eeFooter.kt | 97 +++++++++ .../screens/profile/GroupProfileScreen.kt | 6 + .../main/res/drawable/ic_e2ee_excluded.xml | 16 ++ .../main/res/drawable/ic_e2ee_included.xml | 15 ++ .../internal/ChatGroupAnalyticsTest.kt | 2 + .../internal/ChatGroupCashLinkTest.kt | 2 + .../internal/ChatOpenTranscriptTest.kt | 2 + .../internal/ChatSendFailureAnalyticsTest.kt | 2 + .../com/flipcash/services/chat/E2eePolicy.kt | 6 +- 16 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/E2eeFooter.kt create mode 100644 apps/flipcash/features/messenger/src/main/res/drawable/ic_e2ee_excluded.xml create mode 100644 apps/flipcash/features/messenger/src/main/res/drawable/ic_e2ee_included.xml 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 1b86cb1893..97b1355571 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 @@ -36,6 +36,7 @@ import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen +import com.flipcash.app.messenger.E2eeLearnMoreScreen import com.flipcash.app.messenger.MuteChatScreen import com.flipcash.app.messenger.ProfileScreen import com.flipcash.app.messenger.ReportFlowScreen @@ -127,6 +128,8 @@ fun appEntryProvider( ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } annotatedEntry { key -> MuteChatScreen(key.chatId, key.chatType) } + annotatedEntry { E2eeLearnMoreScreen(forGroup = false) } + annotatedEntry { E2eeLearnMoreScreen(forGroup = true) } // Tokens annotatedEntry(testTag = "token_info_screen") { key -> 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 1ffcdca970..8edae926ca 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 @@ -436,6 +436,25 @@ sealed interface AppRoute : NavKey, Parcelable { Messaging, com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet + + /** + * What end-to-end encryption covers in a DM, opened from the DM profile's footer. A + * [com.getcode.navigation.WrapContentSheet], for the reason [MuteChat] is one. + */ + @Serializable + @Parcelize + data object E2eeDmInfo : + Messaging, + com.getcode.navigation.Sheet, + com.getcode.navigation.WrapContentSheet + + /** The group counterpart of [E2eeDmInfo], opened from Group Info's footer. */ + @Serializable + @Parcelize + data object E2eeGroupInfo : + Messaging, + com.getcode.navigation.Sheet, + com.getcode.navigation.WrapContentSheet } @Serializable diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 5bd40a3315..065b2f31e8 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -981,6 +981,26 @@ This message was deleted This message isn\'t supported on this version + Messages are end-to-end encrypted + Group chats aren\'t end-to-end encrypted + Learn More + + Your messages are private + Messages and photos in this chat are end-to-end encrypted. Only you and the person you’re chatting with can read them. Not even Flipcash can. + Group chats aren’t encrypted + End-to-end encryption covers chats between two people. Messages in group chats are stored on Flipcash servers in a form Flipcash can read. + ENCRYPTED + NOT ENCRYPTED + Text messages and replies + Photos you send + Tips and payments, which are recorded on Solana + Reactions, and who sent them + That a message was edited or deleted + Group chats + Messages sent before encryption was turned on + Messages in DMs + Group chats, including this one + Cash Tap to claim diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt new file mode 100644 index 0000000000..e9606e3d95 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt @@ -0,0 +1,19 @@ +package com.flipcash.app.messenger + +import androidx.compose.runtime.Composable +import com.flipcash.app.messenger.internal.screens.E2eeLearnMoreSheet +import com.flipcash.app.messenger.internal.screens.E2eeSheetKind +import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher + +/** + * The DM or group encryption explainer, behind `AppRoute.Messaging.E2eeDmInfo` and `E2eeGroupInfo`. + */ +@Composable +fun E2eeLearnMoreScreen(forGroup: Boolean) { + // Exit through the sheet so it animates down rather than having its scene deleted mid-frame. + val dismissSheet = LocalBottomSheetDismissDispatcher.current + E2eeLearnMoreSheet( + kind = if (forGroup) E2eeSheetKind.Group else E2eeSheetKind.Dm, + onDismiss = dismissSheet, + ) +} 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 7d6e2b7f45..6d89499dec 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 @@ -53,6 +53,7 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin @@ -183,6 +184,7 @@ data class TypingConstraints( @HiltViewModel internal class ChatViewModel @Inject constructor( private val chatCoordinator: ChatCoordinator, + private val e2eePolicy: E2eePolicy, private val contactCoordinator: ContactCoordinator, private val contactPaymentDelegate: ContactPaymentDelegate, private val tipPaymentDelegate: TipPaymentDelegate, @@ -241,6 +243,8 @@ internal class ChatViewModel @Inject constructor( // bottom bar read it to render the correct (condensed vs expanded) presentation immediately // instead of flashing the expanded white pill while a tip profile loads. val chatType: ChatType = ChatType.UNKNOWN, + /** Whether [E2eePolicy] says this chat's messages are end-to-end encrypted. */ + val isEncrypted: Boolean = false, val chatInputState: TextFieldState = TextFieldState(), val typists: Set = emptySet(), /** What the typing indicator draws ahead of its dots. See [typingAvatars]. */ @@ -561,6 +565,8 @@ internal class ChatViewModel @Inject constructor( /** This chat's viewer state moved, from the stream or from the viewer's own request. */ data class OnViewerStateResolved(val viewerState: ViewerState?) : Event + data class OnEncryptionResolved(val isEncrypted: Boolean) : Event + data class OnCurrencySymbolUpdated(val symbol: String): Event data class OnChatInitFeeUpdated(val formatted: String?) : Event data class OnSendCashReadinessChanged(val ready: Boolean) : Event @@ -1546,6 +1552,16 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnViewerStateResolved(it)) } .launchIn(viewModelScope) + // The profile footer's claim. Decided by the policy from the chat's metadata, so the + // screen never reads the server's flag itself. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeMetadata(it) } + .map { it?.metadata?.let(e2eePolicy::shouldEncrypt) ?: false } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnEncryptionResolved(it)) } + .launchIn(viewModelScope) + // Observed rather than read once: the rule can change under an open screen, and a buy // hydrates a fresher copy of the token. See observeRuleToken for why it fetches as well. stateFlow.map { state -> @@ -2650,6 +2666,8 @@ internal class ChatViewModel @Inject constructor( ) ) } + is Event.OnEncryptionResolved -> { state -> state.copy(isEncrypted = event.isEncrypted) } + is Event.OnViewerStateResolved -> { state -> state.copy(viewerState = event.viewerState) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt new file mode 100644 index 0000000000..e1c3193a24 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt @@ -0,0 +1,199 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.features.messenger.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.BulletRow + +/** Which chat kind the learn-more sheet explains. */ +internal enum class E2eeSheetKind { Dm, Group } + +/** + * What end-to-end encryption does and doesn't cover, opened from the profile footer. One layout + * for both kinds, with the DM's and the group's own words (nodes 10416:1533, 10557:1412). + */ +@Composable +internal fun E2eeLearnMoreSheet( + kind: E2eeSheetKind, + onDismiss: () -> Unit, +) { + val isDm = kind == E2eeSheetKind.Dm + val encrypted = if (isDm) { + listOf(R.string.item_e2eeSheet_textAndReplies, R.string.item_e2eeSheet_photos) + } else { + listOf(R.string.item_e2eeSheet_messagesInDms) + } + val notEncrypted = if (isDm) { + listOf( + R.string.item_e2eeSheet_tipsAndPayments, + R.string.item_e2eeSheet_reactions, + R.string.item_e2eeSheet_editedOrDeleted, + R.string.item_e2eeSheet_groupChats, + R.string.item_e2eeSheet_beforeEncryption, + ) + } else { + listOf( + R.string.item_e2eeSheet_groupChatsIncludingThis, + R.string.item_e2eeSheet_tipsAndPayments, + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + endContent = { AppBarDefaults.Close(onClick = onDismiss) }, + ) + + Box( + modifier = Modifier + .padding(top = 10.dp) + .size(70.dp) + .background(Color.White.copy(alpha = 0.08f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(32.dp), + imageVector = if (isDm) Icons.Filled.Lock else Icons.Filled.LockOpen, + contentDescription = null, + tint = Color.White.copy(alpha = 0.7f), + ) + } + + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = 20.dp), + text = stringResource( + if (isDm) R.string.title_e2eeSheet_dm else R.string.title_e2eeSheet_group + ), + style = CodeTheme.typography.textLarge.copy( + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + ), + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(top = 16.dp), + text = stringResource( + if (isDm) R.string.subtitle_e2eeSheet_dm else R.string.subtitle_e2eeSheet_group + ), + style = CodeTheme.typography.textSmall.copy( + fontSize = 15.sp, + lineHeight = 21.sp, + fontWeight = FontWeight.Medium, + ), + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp) + .padding(top = 40.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SheetSection( + header = R.string.header_e2eeSheet_encrypted, + items = encrypted, + icon = R.drawable.ic_e2ee_included, + ) + SheetSection( + header = R.string.header_e2eeSheet_notEncrypted, + items = notEncrypted, + icon = R.drawable.ic_e2ee_excluded, + ) + } + } +} + +@Composable +private fun SheetSection( + @StringRes header: Int, + items: List, + @DrawableRes icon: Int, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White.copy(alpha = 0.04f), RoundedCornerShape(6.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(header), + style = CodeTheme.typography.caption.copy( + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.44.sp, + ), + color = Color.White.copy(alpha = 0.45f), + ) + for (item in items) { + BulletRow( + painter = painterResource(icon), + text = stringResource(item), + textStyle = CodeTheme.typography.textSmall.copy( + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + ), + textColor = Color.White.copy(alpha = 0.9f), + // The exported glyphs carry their own colours. + iconTint = Color.Unspecified, + spacing = 12.dp, + ) + } + } +} + +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_E2eeSheet_Dm() { + E2eeLearnMoreSheet(kind = E2eeSheetKind.Dm, onDismiss = {}) +} + +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_E2eeSheet_Group() { + E2eeLearnMoreSheet(kind = E2eeSheetKind.Group, onDismiss = {}) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 57e0edd58f..82d41edb61 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -88,6 +88,7 @@ internal fun ChatProfileScreen( chatId = chatState.chatId, chatType = chatState.chatType, viewerState = chatState.viewerState, + isEncrypted = chatState.isEncrypted, ), cashSymbol = chatState.cashSymbol, onBack = { flowNavigator.back() }, @@ -103,8 +104,9 @@ internal fun profileChat( chatId: ChatId?, chatType: ChatType, viewerState: ViewerState?, + isEncrypted: Boolean = false, ): ProfileChat? = when (origin) { - ProfileOrigin.Chat -> ProfileChat(chatId, chatType, viewerState) + ProfileOrigin.Chat -> ProfileChat(chatId, chatType, viewerState, isEncrypted) ProfileOrigin.Mention -> null } @@ -116,6 +118,8 @@ internal data class ProfileChat( val chatId: ChatId?, val chatType: ChatType, val viewerState: ViewerState?, + /** [com.flipcash.services.chat.E2eePolicy]'s answer for this chat; the footer follows it. */ + val isEncrypted: Boolean = false, ) /** @@ -142,6 +146,14 @@ internal fun PersonProfileScreen( topBar = { AppBarWithTitle(onBackIconClicked = onBack) }, + bottomBar = { + if (chat?.isEncrypted == true) { + E2eeFooter( + isEncrypted = true, + onLearnMore = { navigator.push(AppRoute.Messaging.E2eeDmInfo) }, + ) + } + }, ) { innerPadding -> MenuList( modifier = Modifier diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/E2eeFooter.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/E2eeFooter.kt new file mode 100644 index 0000000000..2889467b5b --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/E2eeFooter.kt @@ -0,0 +1,97 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.features.messenger.R +import com.getcode.theme.CodeTheme + +/** + * The encryption note pinned to the bottom of a profile: a lock and one line saying whether the + * chat is end-to-end encrypted, with Learn More under it (nodes 10557:1304, 10557:1643). + * + * Passed as the scaffold's bottom bar rather than put in the list, so it stays put while the list + * scrolls. + */ +@Composable +internal fun E2eeFooter( + isEncrypted: Boolean, + onLearnMore: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(12.dp), + imageVector = if (isEncrypted) Icons.Filled.Lock else Icons.Filled.LockOpen, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + Text( + text = stringResource( + if (isEncrypted) R.string.label_e2eeFooter_dm else R.string.label_e2eeFooter_group + ), + style = CodeTheme.typography.textSmall.copy( + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ), + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + Text( + modifier = Modifier.clickable(onClick = onLearnMore), + text = stringResource(R.string.action_learnMore), + style = CodeTheme.typography.textSmall.copy( + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ), + color = CodeTheme.colors.textMain, + ) + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_E2eeFooter_Dm() { + E2eeFooter(isEncrypted = true, onLearnMore = {}) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_E2eeFooter_Group() { + E2eeFooter(isEncrypted = false, onLearnMore = {}) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index 4111aba867..bcc0072a90 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -103,6 +103,12 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { }, ) }, + bottomBar = { + E2eeFooter( + isEncrypted = false, + onLearnMore = { navigator.push(AppRoute.Messaging.E2eeGroupInfo) }, + ) + }, ) { innerPadding -> MenuList( modifier = Modifier diff --git a/apps/flipcash/features/messenger/src/main/res/drawable/ic_e2ee_excluded.xml b/apps/flipcash/features/messenger/src/main/res/drawable/ic_e2ee_excluded.xml new file mode 100644 index 0000000000..43ada455ca --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/res/drawable/ic_e2ee_excluded.xml @@ -0,0 +1,16 @@ + + + + diff --git a/apps/flipcash/features/messenger/src/main/res/drawable/ic_e2ee_included.xml b/apps/flipcash/features/messenger/src/main/res/drawable/ic_e2ee_included.xml new file mode 100644 index 0000000000..d695ad6c67 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/res/drawable/ic_e2ee_included.xml @@ -0,0 +1,15 @@ + + + + diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 15960c9693..2313708538 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -17,6 +17,7 @@ import com.flipcash.app.session.CashLinkClaims import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.LeaveChatError import com.flipcash.services.models.chat.ChatId @@ -130,6 +131,7 @@ class ChatGroupAnalyticsTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + e2eePolicy = E2eePolicy(null), contactCoordinator = contactCoordinator, contactPaymentDelegate = contactPaymentDelegate, tipPaymentDelegate = tipPaymentDelegate, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index c28d5f0a56..7172415e06 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -16,6 +16,7 @@ import com.flipcash.app.session.ChatCashLinks import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata @@ -134,6 +135,7 @@ class ChatGroupCashLinkTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + e2eePolicy = E2eePolicy(null), contactCoordinator = contactCoordinator, contactPaymentDelegate = contactPaymentDelegate, tipPaymentDelegate = tipPaymentDelegate, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index 7ecf0da66e..fe4f4aaf49 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -6,6 +6,7 @@ import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata @@ -81,6 +82,7 @@ class ChatOpenTranscriptTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + e2eePolicy = E2eePolicy(null), contactCoordinator = mockk(relaxed = true), contactPaymentDelegate = mockk(relaxed = true), tipPaymentDelegate = tipPaymentDelegate, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 9535ff0fc3..4dd9bae0ab 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -18,6 +18,7 @@ import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.TestDispatcherProvider import com.flipcash.services.models.UserProfile import com.flipcash.app.persistence.sources.UserProfileDataSource +import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator @@ -122,6 +123,7 @@ class ChatSendFailureAnalyticsTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + e2eePolicy = E2eePolicy(null), contactCoordinator = contactCoordinator, contactPaymentDelegate = contactPaymentDelegate, tipPaymentDelegate = tipPaymentDelegate, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt index 218cf0f4ff..44c60e3bad 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt @@ -3,6 +3,7 @@ package com.flipcash.services.chat import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.getcode.opencode.model.core.ID +import javax.inject.Inject /** * The @flipcash account's user id. Chats with it stay plaintext, because the backend sends its @@ -23,7 +24,10 @@ val FLIPCASH_ACCOUNT_ID: ID? = null * * @param flipcashAccountId the @flipcash account to exempt; injectable for tests. */ -class E2eePolicy(private val flipcashAccountId: ID? = FLIPCASH_ACCOUNT_ID) { +class E2eePolicy(private val flipcashAccountId: ID?) { + + @Inject + constructor() : this(FLIPCASH_ACCOUNT_ID) fun shouldEncrypt(chat: ChatMetadata): Boolean { val isDm = chat.type == ChatType.CONTACT_DM || chat.type == ChatType.TIP_DM From dbedd372a5b98e0e89d4287b54e305365537076d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 11:57:10 -0400 Subject: [PATCH 4/6] feat(chat): show a two-line bubble for messages this client can't read An encrypted message used to render as a tombstone saying it isn't supported on this version. It now matches node 10416:1576: a dashed bubble with "This message can't be displayed" and "Update Flipcash to see it" underneath. The line underneath is an UndecryptableHint, so decryption can add "Ask {first name} to send it again" and "Try sending it again" for messages that fail to open. Only UpdateApp is produced for now. --- .../core/src/main/res/values/strings.xml | 5 +- .../flipcash/shared/chat/ui/MessageBubble.kt | 14 +- .../shared/chat/ui/UndecryptableBubble.kt | 133 ++++++++++++++++++ .../res/drawable/ic_message_undecryptable.xml | 19 +++ 4 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/res/drawable/ic_message_undecryptable.xml diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 065b2f31e8..a2a284f642 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -979,7 +979,10 @@ Edited You deleted this message This message was deleted - This message isn\'t supported on this version + This message can’t be displayed + Update Flipcash to see it + Ask %1$s to send it again + Try sending it again Messages are end-to-end encrypted Group chats aren\'t end-to-end encrypted diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 4822f98b1f..f0f53a1bda 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -260,16 +260,12 @@ fun ContentBubble( attention = attention, ) - // Not decoded client-side -- see MessageContent.Encrypted. Rendered as a - // tombstone-style text bubble, the same treatment as a deleted message. - is MessageContent.Encrypted -> TextBubble( + // Not decoded client-side -- see MessageContent.Encrypted. Only the update hint + // is produced until decryption exists, since the one message this client can't + // open is one a newer client can. + is MessageContent.Encrypted -> UndecryptableBubble( modifier = modifier, - text = stringResource(R.string.label_messageUnsupported), - isFromSelf = item.isFromSelf, - position = position, - maxWidth = bubbleMaxWidth, - isTombstone = true, - attention = attention, + hint = UndecryptableHint.UpdateApp, ) // TODO diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt new file mode 100644 index 0000000000..7aa572e2d5 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt @@ -0,0 +1,133 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.getcode.theme.CodeTheme + +/** + * What the line under an [UndecryptableBubble] tells the reader to do about it. + * + * Only [UpdateApp] is produced today: the one message this client can't open is one a newer client + * could. The other two are for once decryption exists and a failed one is the sender's to fix. + */ +sealed interface UndecryptableHint { + /** A newer version of the app can read it. */ + data object UpdateApp : UndecryptableHint + + /** It failed to authenticate; the other person sent it and can send it again. */ + data class AskToResend(val firstName: String) : UndecryptableHint + + /** It failed to authenticate; the viewer sent it and can send it again. */ + data object TrySendingAgain : UndecryptableHint +} + +@Composable +private fun UndecryptableHint.text(): String = when (this) { + UndecryptableHint.UpdateApp -> stringResource(R.string.hint_messageUndecryptable_update) + is UndecryptableHint.AskToResend -> + stringResource(R.string.hint_messageUndecryptable_askToResend, firstName) + UndecryptableHint.TrySendingAgain -> stringResource(R.string.hint_messageUndecryptable_tryAgain) +} + +/** + * A message this client can't show: a dashed bubble carrying "This message can't be displayed", + * with a [hint] under it saying what to do about that (node 10416:1576). + */ +@Composable +fun UndecryptableBubble( + hint: UndecryptableHint, + modifier: Modifier = Modifier, + maxWidth: Dp = Dp.Unspecified, +) { + val shape = RoundedCornerShape(12.dp) + val border = Color.White.copy(alpha = 0.2f) + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier + .drawBehind { + val stroke = 1.dp.toPx() + drawRoundRect( + color = border, + topLeft = androidx.compose.ui.geometry.Offset(stroke / 2, stroke / 2), + size = androidx.compose.ui.geometry.Size( + size.width - stroke, + size.height - stroke, + ), + cornerRadius = CornerRadius(12.dp.toPx() - stroke / 2), + style = Stroke( + width = stroke, + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(4.dp.toPx(), 3.dp.toPx()) + ), + ), + ) + } + .padding(horizontal = 12.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(R.drawable.ic_message_undecryptable), + contentDescription = null, + // The exported glyph carries its own colour. + tint = Color.Unspecified, + ) + Text( + text = stringResource(R.string.label_messageUndecryptable), + style = CodeTheme.typography.textMedium.copy( + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + lineHeight = 22.sp, + ), + color = Color.White.copy(alpha = 0.55f), + ) + } + Text( + modifier = Modifier.padding(start = 4.dp), + text = hint.text(), + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.SemiBold), + color = Color.White.copy(alpha = 0.7f), + ) + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UndecryptableBubble() { + UndecryptableBubble(hint = UndecryptableHint.UpdateApp, modifier = Modifier.padding(16.dp)) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_UndecryptableBubble_AskToResend() { + UndecryptableBubble( + hint = UndecryptableHint.AskToResend("Ted"), + modifier = Modifier.padding(16.dp), + ) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/res/drawable/ic_message_undecryptable.xml b/apps/flipcash/shared/chat-ui/src/main/res/drawable/ic_message_undecryptable.xml new file mode 100644 index 0000000000..be35201b6d --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/res/drawable/ic_message_undecryptable.xml @@ -0,0 +1,19 @@ + + + + + From 2706d40881fa44a856ade47d076e6555f0d28df1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 12:06:49 -0400 Subject: [PATCH 5/6] test(chat): render the E2EE profiles, sheets and bubble to PNG The DM profile with its footer on, Group Info, both learn-more sheets and the unreadable-message bubble with each hint, written to build/screenshots/ for comparison with nodes 10557:1304, 10557:1643, 10416:1533, 10557:1412 and 10416:1576. Same Robolectric mechanics as ChatIdentityScreenshotTest; the profiles are built from the screens' scaffold, header, rows and footer because the screens take their view models. --- .../messenger/internal/E2eeScreenshotTest.kt | 148 ++++++++++++++++++ .../ui/UndecryptableBubbleScreenshotTest.kt | 66 ++++++++ 2 files changed, 214 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/E2eeScreenshotTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubbleScreenshotTest.kt diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/E2eeScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/E2eeScreenshotTest.kt new file mode 100644 index 0000000000..881d823c23 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/E2eeScreenshotTest.kt @@ -0,0 +1,148 @@ +package com.flipcash.app.messenger.internal + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.menu.MenuList +import com.flipcash.app.messenger.internal.screens.E2eeLearnMoreSheet +import com.flipcash.app.messenger.internal.screens.E2eeSheetKind +import com.flipcash.app.messenger.internal.screens.profile.BlockUser +import com.flipcash.app.messenger.internal.screens.profile.E2eeFooter +import com.flipcash.app.messenger.internal.screens.profile.GroupProfileHeader +import com.flipcash.app.messenger.internal.screens.profile.InviteToGroup +import com.flipcash.app.messenger.internal.screens.profile.LeaveChat +import com.flipcash.app.messenger.internal.screens.profile.MuteChat +import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader +import com.flipcash.app.messenger.internal.screens.profile.ReportGroup +import com.flipcash.app.messenger.internal.screens.profile.ReportUser +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.features.messenger.R +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeScaffold +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders the end-to-end encryption surfaces to PNGs for comparison with the design: the DM + * profile with its footer on, Group Info, and both learn-more sheets. Not an assertion test — it + * writes to `build/screenshots/`, with the same mechanics as `ChatIdentityScreenshotTest`. + * + * The profiles are assembled from the screens' own pieces rather than the screens themselves, + * which take their view models. The scaffold, header, rows and footer are the ones they use. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w402dp-h874dp-xhdpi") +class E2eeScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun rendersDmProfileWithFooter() = render("e2ee_dm_profile.png") { + val person = ChatParticipant.TipUser( + userId = listOf(1.toByte()), + profile = UserProfile.Empty.copy(displayName = "Grace Hopper", username = "grace_hopper"), + ) + CodeScaffold( + topBar = { AppBarWithTitle(onBackIconClicked = {}) }, + bottomBar = { E2eeFooter(isEncrypted = true, onLearnMore = {}) }, + ) { innerPadding -> + MenuList( + modifier = Modifier.fillMaxSize().padding(innerPadding), + items = listOf(ReportUser, BlockUser), + header = { + ProfileHeader( + participant = person, + joinDate = null, + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x7, bottom = CodeTheme.dimens.grid.x8), + ) + }, + onItemClick = {}, + endSlot = { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + }, + ) + } + } + + @Test + fun rendersGroupInfo() = render("e2ee_group_info.png") { + val group = ChatSubject.Group( + chatId = ChatId(listOf(1.toByte())), + groupTitle = "Ballers", + picture = null, + memberCount = 3, + rules = null, + isMember = true, + ) + CodeScaffold( + topBar = { AppBarWithTitle(onBackIconClicked = {}) }, + bottomBar = { E2eeFooter(isEncrypted = false, onLearnMore = {}) }, + ) { innerPadding -> + MenuList( + modifier = Modifier.fillMaxSize().padding(innerPadding), + items = listOf(InviteToGroup, MuteChat, ReportGroup, LeaveChat), + header = { + GroupProfileHeader( + group = group, + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x7, bottom = CodeTheme.dimens.grid.x8), + ) + }, + onItemClick = {}, + ) + } + } + + @Test + fun rendersDmSheet() = render("e2ee_sheet_dm.png") { + E2eeLearnMoreSheet(kind = E2eeSheetKind.Dm, onDismiss = {}) + } + + @Test + fun rendersGroupSheet() = render("e2ee_sheet_group.png") { + E2eeLearnMoreSheet(kind = E2eeSheetKind.Group, onDismiss = {}) + } + + private fun render(name: String, content: @Composable () -> Unit) { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { content() } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${bitmap.width}x${bitmap.height})") + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubbleScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubbleScreenshotTest.kt new file mode 100644 index 0000000000..541c98cd00 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubbleScreenshotTest.kt @@ -0,0 +1,66 @@ +package com.flipcash.shared.chat.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashTheme +import com.getcode.theme.CodeTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders the bubble for a message this client can't read, with each hint, to a PNG for + * comparison with the design (node 10416:1576). Not an assertion test — it writes to + * `build/screenshots/`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w402dp-h400dp-xhdpi") +class UndecryptableBubbleScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun rendersUndecryptableBubble() { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashTheme { + Column( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.background) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + UndecryptableBubble(hint = UndecryptableHint.UpdateApp) + UndecryptableBubble(hint = UndecryptableHint.AskToResend("Grace")) + UndecryptableBubble(hint = UndecryptableHint.TrySendingAgain) + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, "undecryptable_bubble.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${bitmap.width}x${bitmap.height})") + } +} From 1e6f69c84a92b31947cbdc652082af2c9e6dad5c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 12:15:28 -0400 Subject: [PATCH 6/6] fix(chat): use straight apostrophes in the E2EE strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the new strings used ’ while the group footer used ', which is what most of strings.xml uses. --- apps/flipcash/core/src/main/res/values/strings.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a2a284f642..632b037d4b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -979,7 +979,7 @@ Edited You deleted this message This message was deleted - This message can’t be displayed + This message can\'t be displayed Update Flipcash to see it Ask %1$s to send it again Try sending it again @@ -989,8 +989,8 @@ Learn More Your messages are private - Messages and photos in this chat are end-to-end encrypted. Only you and the person you’re chatting with can read them. Not even Flipcash can. - Group chats aren’t encrypted + Messages and photos in this chat are end-to-end encrypted. Only you and the person you\'re chatting with can read them. Not even Flipcash can. + Group chats aren\'t encrypted End-to-end encryption covers chats between two people. Messages in group chats are stored on Flipcash servers in a form Flipcash can read. ENCRYPTED NOT ENCRYPTED