From 6034fb8bcc34e97a4386f63f4d249a4cf579fbda Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 11:39:54 -0400 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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 From d692391c0f10a7fc9831cbc5621c131e09cfd805 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 13:26:44 -0400 Subject: [PATCH 07/14] refactor(chat): make E2eePolicy delegate to the shared ChatEncryptionPolicy The @flipcash exemption now comes from ChatEncryptionPolicy, which both apps share, so the app's own null constant and the test for its unset state are gone. CONTACT_DM and TIP_DM map to isDirectMessage, matching the server's IsDmChatType. --- .../internal/ChatGroupAnalyticsTest.kt | 2 +- .../internal/ChatGroupCashLinkTest.kt | 2 +- .../internal/ChatOpenTranscriptTest.kt | 2 +- .../internal/ChatSendFailureAnalyticsTest.kt | 2 +- services/flipcash/build.gradle.kts | 1 + .../com/flipcash/services/chat/E2eePolicy.kt | 38 ++++++++----------- .../flipcash/services/chat/E2eePolicyTest.kt | 13 ++----- 7 files changed, 25 insertions(+), 35 deletions(-) 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 2313708538..5b5448fc3d 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 @@ -131,7 +131,7 @@ class ChatGroupAnalyticsTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, - e2eePolicy = E2eePolicy(null), + e2eePolicy = E2eePolicy(), 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 7172415e06..ddc8d35de2 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 @@ -135,7 +135,7 @@ class ChatGroupCashLinkTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, - e2eePolicy = E2eePolicy(null), + e2eePolicy = E2eePolicy(), 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 fe4f4aaf49..bf035ec623 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 @@ -82,7 +82,7 @@ class ChatOpenTranscriptTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, - e2eePolicy = E2eePolicy(null), + e2eePolicy = E2eePolicy(), 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 4dd9bae0ab..60cd9a9c3b 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 @@ -123,7 +123,7 @@ class ChatSendFailureAnalyticsTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, - e2eePolicy = E2eePolicy(null), + e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, contactPaymentDelegate = contactPaymentDelegate, tipPaymentDelegate = tipPaymentDelegate, diff --git a/services/flipcash/build.gradle.kts b/services/flipcash/build.gradle.kts index e8fc17062d..a900cad47d 100644 --- a/services/flipcash/build.gradle.kts +++ b/services/flipcash/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { api(project(":libs:network:jwt")) api(project(":services:opencode")) implementation(project(":ui:resources")) + implementation(project(":libs:encryption:chat-cipher")) implementation(libs.javax.inject) 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 44c60e3bad..6ed1f8f9af 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 @@ -2,37 +2,31 @@ 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 com.getcode.chatcipher.ChatEncryptionPolicy import javax.inject.Inject -/** - * 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. + * This is the only reader of [ChatMetadata.useE2ee] in the app. It maps a chat onto + * [ChatEncryptionPolicy], which both apps share, so the two platforms can't disagree on when a DM + * is encrypted. The @flipcash exemption lives there. */ -class E2eePolicy(private val flipcashAccountId: ID?) { - - @Inject - constructor() : this(FLIPCASH_ACCOUNT_ID) +class E2eePolicy @Inject constructor() { fun shouldEncrypt(chat: ChatMetadata): Boolean { + // CONTACT_DM and TIP_DM, matching the server's IsDmChatType. 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 } + // Every member is checked rather than picking out the peer, so the answer doesn't depend + // on knowing which entry is the viewer. The viewer is never @flipcash. + val members = chat.members.map { it.userId.toByteArray() }.ifEmpty { listOf(ByteArray(0)) } + return members.all { userId -> + ChatEncryptionPolicy.shouldEncrypt( + isDirectMessage = isDm, + useE2ee = chat.useE2ee, + peerUserId = userId, + ) + } } } 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 index 8219af6d39..3382fc3307 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/E2eePolicyTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/E2eePolicyTest.kt @@ -5,6 +5,7 @@ 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 com.getcode.chatcipher.ChatEncryptionPolicy import org.junit.Test import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -12,9 +13,9 @@ 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 val flipcash = ChatEncryptionPolicy.FLIPCASH_USER_ID.toList() + private val friend = List(16) { 2.toByte() } + private val policy = E2eePolicy() private fun member(id: List) = ChatMember(userId = id, userProfile = UserProfile.Empty, pointers = emptyList()) @@ -53,10 +54,4 @@ class E2eePolicyTest { 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 bc4a346f78a5f59c524576d4d0ea11099f49eeb8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 13:44:22 -0400 Subject: [PATCH 08/14] feat(chat): seal and open DM content with ChatCipher ChatContentCrypto turns Text and Reply(Text) into EncryptedContent and back, using the viewer's account key pair and the peer's registered owner key from the Resolver. The derived chat key is cached per chat and account, so a transcript costs one key fetch. Opening sorts failures by what the UI should say: an unknown scheme or a plaintext type this client doesn't render (including media) is Unsupported, a failed authentication or a sender who isn't a member is Authentication, and a failed key fetch is KeyPending so the message can be retried instead of shown as broken. ChatCipher is injected through Hilt so tests can use a fake; DefaultChatCipher's libsodium binding doesn't load on a JVM host. --- .../services/chat/ChatContentCrypto.kt | 158 +++++++++++++ .../flipcash/services/chat/ChatKeySource.kt | 36 +++ .../services/chat/MessageEncryption.kt | 40 ++++ .../services/inject/ChatCryptoModule.kt | 24 ++ .../network/extensions/LocalToProtobuf.kt | 7 +- .../network/extensions/ProtobufToLocal.kt | 5 +- .../com/flipcash/services/models/Errors.kt | 9 +- .../services/models/chat/ChatMessage.kt | 4 + .../services/models/chat/ChatMetadata.kt | 2 +- .../services/models/chat/MessageContent.kt | 8 +- .../services/chat/ChatContentCryptoTest.kt | 212 ++++++++++++++++++ 11 files changed, 485 insertions(+), 20 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt new file mode 100644 index 0000000000..3131ebb144 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt @@ -0,0 +1,158 @@ +package com.flipcash.services.chat + +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.flipcash.services.internal.network.extensions.asContent +import com.flipcash.services.internal.network.extensions.toMessageContent +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.getcode.chatcipher.ChatCipher +import com.getcode.chatcipher.ChatCipherException +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.opencode.model.core.ID +import com.google.protobuf.InvalidProtocolBufferException +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Seals and opens DM content as `messaging.v1.EncryptedContent`. + * + * Whether to seal is not decided here; that is [E2eePolicy]. This only knows how, and keeps each + * chat's derived key so a transcript costs one key fetch rather than one per message. + */ +@Singleton +class ChatContentCrypto @Inject constructor( + private val cipher: ChatCipher, + private val keys: ChatKeySource, +) { + private class ChatKeys(val ownPk: ByteArray, val peerPk: ByteArray, val chatKey: ByteArray) + + // The own public key is part of the key so a different account on this device never reuses + // another account's chat keys. + private data class CacheKey(val chatId: ID, val ownPk: ID, val peerId: ID) + + private val chatKeys = ConcurrentHashMap() + + /** + * Encrypts [content] from the viewer to [peerId]. Only Text, and a Reply whose body is Text, + * are sealed; media is not sent encrypted yet. + */ + suspend fun seal(chatId: ChatId, peerId: ID, content: MessageContent): Result { + if (!content.isSealable()) { + return Result.failure(IllegalArgumentException("Cannot encrypt ${content::class.simpleName}")) + } + val chatKeys = chatKeys(chatId, peerId).getOrElse { return Result.failure(it) } + return runCatching { + val payload = cipher.encrypt( + content = content.asContent().toByteArray(), + chatKey = chatKeys.chatKey, + senderPk = chatKeys.ownPk, + recipientPk = chatKeys.peerPk, + chatId = chatId.bytes, + ) + MessageContent.Encrypted( + scheme = SCHEME_X25519_XCHACHA20POLY1305, + nonce = payload.nonce, + ciphertext = payload.ciphertext, + ) + } + } + + /** + * Decrypts [encrypted], a message [senderId] sent in the DM between [selfId] and [peerId]. + */ + suspend fun open( + chatId: ChatId, + selfId: ID, + peerId: ID, + senderId: ID?, + encrypted: MessageContent.Encrypted, + ): OpenedContent { + if (encrypted.scheme != SCHEME_X25519_XCHACHA20POLY1305) { + return OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + + val chatKeys = chatKeys(chatId, peerId).getOrElse { cause -> + // A peer key the cipher rejects will be rejected again; anything else is a failed + // fetch, which a later attempt can get past. + return if (cause is ChatCipherException) { + OpenedContent.Undecryptable(UndecryptableReason.Authentication) + } else { + OpenedContent.KeyPending + } + } + + val (senderPk, recipientPk) = when (senderId) { + selfId -> chatKeys.ownPk to chatKeys.peerPk + peerId -> chatKeys.peerPk to chatKeys.ownPk + else -> return OpenedContent.Undecryptable(UndecryptableReason.Authentication) + } + + val plaintext = try { + cipher.decrypt( + payload = EncryptedPayload(nonce = encrypted.nonce, ciphertext = encrypted.ciphertext), + chatKey = chatKeys.chatKey, + senderPk = senderPk, + recipientPk = recipientPk, + chatId = chatId.bytes, + ) + } catch (_: ChatCipherException) { + return OpenedContent.Undecryptable(UndecryptableReason.Authentication) + } + + val content = try { + MessagingModel.Content.parseFrom(plaintext) + } catch (_: InvalidProtocolBufferException) { + return OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + + return if (content.isRenderable()) { + OpenedContent.Plaintext(content.toMessageContent()) + } else { + OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + } + + fun clear() { + chatKeys.clear() + } + + private suspend fun chatKeys(chatId: ChatId, peerId: ID): Result { + val own = keys.ownKeyPair() + ?: return Result.failure(IllegalStateException("No account key pair")) + val cacheKey = CacheKey(chatId.bytes.toList(), own.publicKey.toList(), peerId) + chatKeys[cacheKey]?.let { return Result.success(it) } + + val peerPk = keys.peerPublicKey(peerId).getOrElse { return Result.failure(it) } + return runCatching { + ChatKeys( + ownPk = own.publicKey, + peerPk = peerPk, + chatKey = cipher.chatKey(own, peerPk, chatId.bytes), + ) + }.onSuccess { chatKeys[cacheKey] = it } + } + + companion object { + /** `EncryptedContent.Scheme.X25519_XCHACHA20POLY1305`. */ + const val SCHEME_X25519_XCHACHA20POLY1305 = 1 + } +} + +private fun MessageContent.isSealable(): Boolean = when (this) { + is MessageContent.Text -> true + is MessageContent.Reply -> content.isNotEmpty() && content.all { it is MessageContent.Text } + else -> false +} + +/** + * The spec allows Text, Media, and a Reply of either. Media isn't rendered from an encrypted + * message yet, so it takes the same "update" path as a type this client has never heard of. + */ +private fun MessagingModel.Content.isRenderable(): Boolean = when (typeCase) { + MessagingModel.Content.TypeCase.TEXT -> true + MessagingModel.Content.TypeCase.REPLY -> + reply.contentCount > 0 && + reply.contentList.all { it.typeCase == MessagingModel.Content.TypeCase.TEXT } + else -> false +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt new file mode 100644 index 0000000000..86bb6749b6 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt @@ -0,0 +1,36 @@ +package com.flipcash.services.chat + +import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.user.UserManager +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import javax.inject.Inject + +/** The Ed25519 keys a DM is encrypted between. */ +interface ChatKeySource { + /** The viewer's account key pair, or null when no account is loaded. */ + fun ownKeyPair(): KeyPair? + + /** The Ed25519 public key [userId] registered their account with. */ + suspend fun peerPublicKey(userId: ID): Result +} + +/** + * Reads the peer's key from the Resolver: resolving a user id returns the owner key the account + * registered with, the same key a tip to that user is sent to. + */ +internal class DefaultChatKeySource @Inject constructor( + private val userManager: UserManager, + private val resolver: ResolverController, +) : ChatKeySource { + + override fun ownKeyPair(): KeyPair? = + userManager.accountCluster?.authority?.keyPair?.let { keyPair -> + // The orlp private key is the SHA-512 expansion of the seed, which is what the + // cipher's X25519 conversion reads. + KeyPair(publicKey = keyPair.publicKeyBytes, privateKey = keyPair.privateKeyBytes) + } + + override suspend fun peerPublicKey(userId: ID): Result = + resolver.resolve(userId).map { it.byteArray } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt new file mode 100644 index 0000000000..8d0557b9f7 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt @@ -0,0 +1,40 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.MessageContent + +/** + * How an end-to-end encrypted message stands on this device. Null on a [ChatMessage] means the + * message was sent in plaintext. + * + * [com.flipcash.services.models.chat.ChatMessage.content] follows it: the decrypted content when + * [Decrypted], the raw [MessageContent.Encrypted] otherwise. + */ +sealed interface MessageEncryption { + data object Decrypted : MessageEncryption + + /** + * The chat key could not be derived because a key fetch failed. Not a decrypt failure: the + * message is hidden and opened again later, rather than shown as undecryptable. + */ + data object KeyPending : MessageEncryption + + data class Undecryptable(val reason: UndecryptableReason) : MessageEncryption +} + +enum class UndecryptableReason { + /** + * An unknown scheme, or a plaintext type this client doesn't render. A newer client can read + * it. + */ + Unsupported, + + /** The ciphertext failed authentication on a scheme this client supports. */ + Authentication, +} + +/** The outcome of opening one [MessageContent.Encrypted]. */ +sealed interface OpenedContent { + data class Plaintext(val content: MessageContent) : OpenedContent + data object KeyPending : OpenedContent + data class Undecryptable(val reason: UndecryptableReason) : OpenedContent +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt new file mode 100644 index 0000000000..7c3317d7f4 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt @@ -0,0 +1,24 @@ +package com.flipcash.services.inject + +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.DefaultChatKeySource +import com.getcode.chatcipher.ChatCipher +import com.getcode.chatcipher.DefaultChatCipher +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class ChatCryptoModule { + + @Binds + abstract fun bindChatKeySource(source: DefaultChatKeySource): ChatKeySource + + companion object { + @Provides + fun provideChatCipher(): ChatCipher = DefaultChatCipher + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index a0759eb81b..6f44bf74e0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -171,12 +171,7 @@ internal fun MessageContent.asContent(): MessagingModel.Content { .setDeleted(deletedBuilder) .build() } - // The client never constructs this locally from scratch -- it only exists as a decode - // result for incoming `Content.encrypted` (see MessageContent.Encrypted). An edit that - // rewrites the text of a message envelope containing this (e.g. a reply body) re-sends - // the untouched fields verbatim, so this is a faithful round-trip of the original wire - // bytes, not new encryption. Encrypting new content needs its own crypto implementation - // (X25519/HKDF/XChaCha20), tracked separately. + // Sealed by ChatContentCrypto, or relayed verbatim when an edit rewrites around it. is MessageContent.Encrypted -> MessagingModel.Content.newBuilder() .setEncrypted( MessagingModel.EncryptedContent.newBuilder() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 6c0cff564b..7a5a3cc695 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -194,10 +194,7 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent { deletedTs = Instant.fromEpochSeconds(deleted.deletedTs.seconds, deleted.deletedTs.nanos), deletedBy = if (deleted.hasDeletedBy()) deleted.deletedBy.toId() else null, ) - // Not decoded: E2EE crypto (X25519/HKDF/XChaCha20) is a cross-platform parity hotspot - // that needs its own decision. Rendered as unsupported rather than dropped, but the raw - // fields are kept verbatim so the message can round-trip through storage and be - // faithfully re-encoded (e.g. on edit) without losing the ciphertext. + // Kept as ciphertext here; ChatContentCrypto opens it before it's stored. MessagingModel.Content.TypeCase.ENCRYPTED -> MessageContent.Encrypted( scheme = encrypted.schemeValue, nonce = encrypted.nonce.toByteArray(), diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index d9a91dcdfe..b03cf69ca6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -469,8 +469,9 @@ sealed class SendMessageError( override val cause: Throwable? = null ): CodeServerError(message, cause) { class Denied : SendMessageError("Denied") - // Sender attempted to send EncryptedContent to a chat that doesn't allow it (non-DM). - // The client never constructs EncryptedContent today, so this is defensive. + // Sender sent EncryptedContent to a chat that doesn't allow it (non-DM). E2eePolicy only + // encrypts in a DM, so this means the chat's type changed under us; the send fails and can + // be retried. class EncryptionNotAllowed : SendMessageError("Encryption not allowed") class Unrecognized : SendMessageError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : SendMessageError(message = cause?.message, cause = cause), NotifiableError @@ -533,8 +534,8 @@ sealed class EditMessageError( class MessageNotFound : EditMessageError("Message not found") class CannotEdit : EditMessageError("Cannot edit") class Conflict : EditMessageError("Conflict") - // Editor attempted to send EncryptedContent to a chat that doesn't allow it (non-DM). - // The client never constructs EncryptedContent today, so this is defensive. + // Editor sent EncryptedContent to a chat that doesn't allow it (non-DM). See + // SendMessageError.EncryptionNotAllowed. class EncryptionNotAllowed : EditMessageError("Encryption not allowed") class Unrecognized : EditMessageError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : EditMessageError(message = cause?.message, cause = cause), NotifiableError diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt index a3cb7ee7fd..75ccee2baa 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt @@ -1,5 +1,6 @@ package com.flipcash.services.models.chat +import com.flipcash.services.chat.MessageEncryption import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -18,4 +19,7 @@ data class ChatMessage( // Set when this copy was redacted for the viewer: it exists, but its content is a // placeholder. See messaging.v1.Message.redacted. val redacted: Boolean = false, + // Null for a plaintext message. For an end-to-end encrypted one, whether it was opened on this + // device; [content] holds the plaintext only when it was. + val encryption: MessageEncryption? = null, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt index b39e60cd69..c530d526a0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt @@ -26,6 +26,6 @@ data class ChatMetadata( // Group creator; null for DMs and for group chats reconstructed without a server round trip. val creator: ID? = null, // Transitional E2EE flag (DMs only): true means clients should send new content as - // EncryptedContent. Ignored behaviourally for now -- see chat/v1 model.proto. + // EncryptedContent. Read only by E2eePolicy -- see chat/v1 model.proto. val useE2ee: Boolean = false, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index ec5572ae7e..d08f12a3fb 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -33,11 +33,9 @@ sealed interface MessageContent { val deletedTs: Instant, val deletedBy: ID?, ) : MessageContent - // Placeholder for `messaging.v1.Content.encrypted` (DMs only). Crypto (X25519/HKDF/ - // XChaCha20) is a cross-platform parity hotspot and needs its own decision; until then this - // renders as an unsupported message rather than being decoded. The raw fields are kept - // verbatim (not decrypted) so the ciphertext survives persistence and can be faithfully - // re-encoded, rather than being lost the moment it is stored locally. + // `messaging.v1.Content.encrypted` (DMs only), as it travels on the wire. Decrypted by + // ChatContentCrypto on the way into storage; a message that opened carries its plaintext in + // ChatMessage.content instead, and this only remains on one that didn't. data class Encrypted( val scheme: Int, val nonce: ByteArray, diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt new file mode 100644 index 0000000000..8981935f69 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt @@ -0,0 +1,212 @@ +package com.flipcash.services.chat + +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.getcode.chatcipher.ChatCipher +import com.getcode.chatcipher.ChatCipherException +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ChatContentCryptoTest { + + private val chatId = ChatId(ByteArray(32) { 7 }) + private val self: ID = List(16) { 1 } + private val peer: ID = List(16) { 2 } + private val selfKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val peerKeys = KeyPair(publicKey = ByteArray(32) { 21 }, privateKey = ByteArray(64) { 22 }) + + private class Keys(own: KeyPair, private val peerPk: ByteArray) : ChatKeySource { + var own: KeyPair? = own + var peerFails: Throwable? = null + var fetches = 0 + override fun ownKeyPair() = own + override suspend fun peerPublicKey(userId: ID): Result { + fetches++ + return peerFails?.let { Result.failure(it) } ?: Result.success(peerPk) + } + } + + private val selfSide = Keys(selfKeys, peerKeys.publicKey) + private val peerSide = Keys(peerKeys, selfKeys.publicKey) + private val mine = ChatContentCrypto(FakeChatCipher, selfSide) + private val theirs = ChatContentCrypto(FakeChatCipher, peerSide) + + private suspend fun sealFromPeer(content: MessageContent) = + theirs.seal(chatId, peerId = self, content = content).getOrThrow() + + private suspend fun open(encrypted: MessageContent.Encrypted, senderId: ID? = peer) = + mine.open(chatId, selfId = self, peerId = peer, senderId = senderId, encrypted = encrypted) + + @Test + fun `text from the peer opens`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")) + + assertEquals(OpenedContent.Plaintext(MessageContent.Text("hi")), open(sealed)) + } + + @Test + fun `a reply to text opens with its quote`() = runTest { + val reply = MessageContent.Reply(repliedMessageId = 4, content = listOf(MessageContent.Text("yes"))) + + assertEquals(OpenedContent.Plaintext(reply), open(sealFromPeer(reply))) + } + + @Test + fun `the viewer's own message opens`() = runTest { + val sealed = mine.seal(chatId, peerId = peer, content = MessageContent.Text("mine")).getOrThrow() + + assertEquals(OpenedContent.Plaintext(MessageContent.Text("mine")), open(sealed, senderId = self)) + } + + @Test + fun `media is not sealed`() = runTest { + val result = mine.seal(chatId, peer, MessageContent.Media(items = emptyList(), caption = null)) + + assertTrue(result.isFailure) + } + + @Test + fun `an unknown scheme asks for an update without fetching keys`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")).copy(scheme = 2) + + assertEquals(OpenedContent.Undecryptable(UndecryptableReason.Unsupported), open(sealed)) + assertEquals(0, selfSide.fetches) + } + + @Test + fun `a plaintext type outside text and reply asks for an update`() = runTest { + val media = MessagingModel.Content.newBuilder() + .setMedia(MessagingModel.MediaContent.getDefaultInstance()) + .build() + val sealed = sealRaw(media.toByteArray()) + + assertEquals(OpenedContent.Undecryptable(UndecryptableReason.Unsupported), open(sealed)) + } + + @Test + fun `a reply to media asks for an update`() = runTest { + val replyToMedia = MessagingModel.Content.newBuilder() + .setReply( + MessagingModel.ReplyContent.newBuilder() + .setRepliedMessageId(MessagingModel.MessageId.newBuilder().setValue(1)) + .addContent( + MessagingModel.Content.newBuilder() + .setMedia(MessagingModel.MediaContent.getDefaultInstance()) + ) + ) + .build() + + assertEquals( + OpenedContent.Undecryptable(UndecryptableReason.Unsupported), + open(sealRaw(replyToMedia.toByteArray())), + ) + } + + @Test + fun `a tampered ciphertext fails authentication`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")) + val tampered = sealed.copy(ciphertext = sealed.ciphertext.copyOf().also { it[0] = (it[0] + 1).toByte() }) + + assertEquals(OpenedContent.Undecryptable(UndecryptableReason.Authentication), open(tampered)) + } + + @Test + fun `a message attributed to the wrong sender fails authentication`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")) + + assertEquals( + OpenedContent.Undecryptable(UndecryptableReason.Authentication), + open(sealed, senderId = self), + ) + } + + @Test + fun `a failed key fetch is pending, not undecryptable`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")) + selfSide.peerFails = IOException("offline") + + assertEquals(OpenedContent.KeyPending, open(sealed)) + + selfSide.peerFails = null + assertIs(open(sealed)) + } + + @Test + fun `no account key pair is pending`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")) + selfSide.own = null + + assertEquals(OpenedContent.KeyPending, open(sealed)) + } + + @Test + fun `the chat key is fetched once per chat`() = runTest { + repeat(3) { open(sealFromPeer(MessageContent.Text("$it"))) } + + assertEquals(1, selfSide.fetches) + } + + private suspend fun sealRaw(plaintext: ByteArray): MessageContent.Encrypted { + val chatKey = FakeChatCipher.chatKey(peerKeys, selfKeys.publicKey, chatId.bytes) + val payload = FakeChatCipher.encrypt(plaintext, chatKey, peerKeys.publicKey, selfKeys.publicKey, chatId.bytes) + return MessageContent.Encrypted(ChatContentCrypto.SCHEME_X25519_XCHACHA20POLY1305, payload.nonce, payload.ciphertext) + } +} + +/** + * Stands in for [com.getcode.chatcipher.DefaultChatCipher], whose libsodium binding can't load on + * a JVM host. It keeps the properties callers depend on: both members derive the same chat key, + * and opening fails unless the key, the sender/recipient order and the bytes all match. + */ +internal object FakeChatCipher : ChatCipher { + private const val HEADER = 32 * 3 + + override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray = + ByteArray(32) { i -> (ownKeyPair.publicKey[i].toInt() xor peerPublicKey[i].toInt() xor chatId[i].toInt()).toByte() } + + override fun encrypt( + content: ByteArray, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + ) = EncryptedPayload(nonce = ByteArray(24), ciphertext = chatKey + senderPk + recipientPk + content) + + override fun decrypt( + payload: EncryptedPayload, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + ): ByteArray { + val header = payload.ciphertext.copyOfRange(0, HEADER) + if (!header.contentEquals(chatKey + senderPk + recipientPk)) throw ChatCipherException("authentication failed") + return payload.ciphertext.copyOfRange(HEADER, payload.ciphertext.size) + } + + override fun encryptBlob( + image: ByteArray, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + blobId: ByteArray, + ): ByteArray = throw UnsupportedOperationException() + + override fun decryptBlob( + blob: ByteArray, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + blobId: ByteArray, + ): ByteArray = throw UnsupportedOperationException() +} From 8aae8f9dc4f5783d9b3e946e543ffead0bc1eae9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 13:49:53 -0400 Subject: [PATCH 09/14] feat(chat): encrypt DM text and replies on send When E2eePolicy says a DM encrypts, sendMessage, retryMessage and editMessage seal Text and Reply(Text) before the request. The pending row and the stored reply keep the plaintext, so the viewer's own message never has to be decrypted back. An edit stays encrypted if the original was, even after the chat stops encrypting; a plaintext message edited in a chat that now encrypts goes out sealed. A send whose chat can't be read, whose peer key can't be fetched, or that the server refuses with EncryptionNotAllowed fails its pending row, so the transcript offers a retry rather than sending plaintext. The fake ChatCipher moves to :services:flipcash testFixtures so the chat module's tests can seal and open with it. --- apps/flipcash/shared/chat/build.gradle.kts | 1 + .../flipcash/shared/chat/inject/ChatModule.kt | 7 + .../chat/internal/OutgoingEncryption.kt | 107 ++++++ .../internal/delegates/MessagingDelegate.kt | 51 ++- .../chat/MessagingSendEncryptionTest.kt | 326 ++++++++++++++++++ services/flipcash/build.gradle.kts | 8 +- .../services/chat/ChatContentCryptoTest.kt | 53 --- .../flipcash/services/chat/FakeChatCipher.kt | 56 +++ 8 files changed, 542 insertions(+), 67 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt create mode 100644 services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index afa205cec9..8a52e3379d 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(project(":apps:flipcash:shared:analytics")) testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":services:flipcash")) + testImplementation(testFixtures(project(":services:flipcash"))) implementation(project(":libs:network:connectivity:public")) implementation(project(":libs:emojis")) implementation(libs.androidx.lifecycle.process) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index e00d8f11f5..89c8682e77 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -2,6 +2,8 @@ package com.flipcash.shared.chat.inject import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore +import com.flipcash.shared.chat.internal.DmOutgoingEncryption +import com.flipcash.shared.chat.internal.OutgoingEncryption import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.RealChatDraftStore import com.getcode.opencode.providers.SessionListener @@ -28,6 +30,11 @@ abstract class ChatModule { impl: RealChatDraftStore ): ChatDraftStore + @Binds + internal abstract fun bindOutgoingEncryption( + impl: DmOutgoingEncryption + ): OutgoingEncryption + @Binds @IntoSet abstract fun bindSessionListener( diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt new file mode 100644 index 0000000000..581655ad90 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt @@ -0,0 +1,107 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +/** + * Decides what goes on the wire for a message the viewer sends or edits, and seals it when + * [E2eePolicy] says so. + * + * The local copy of an outgoing message is always its plaintext; only the request carries + * ciphertext. [Outgoing.echo] puts the plaintext back into the server's reply, so the stored row + * never has to be decrypted from the viewer's own send. + */ +interface OutgoingEncryption { + + /** + * What to send in [chatId] for [content]. + * + * [wasEncrypted] is for an edit: an encrypted message is never rewritten in plaintext, even if + * the chat has since stopped encrypting. + * + * Fails when the chat can't be read, since a chat that should be encrypted would otherwise go + * out in plaintext, and when sealing fails. Either way the send is failed and can be retried. + */ + suspend fun prepare( + chatId: ChatId, + content: List, + wasEncrypted: Boolean = false, + ): Result + + /** Sends everything in plaintext. What a delegate built without one -- a unit test -- is given. */ + object None : OutgoingEncryption { + override suspend fun prepare( + chatId: ChatId, + content: List, + wasEncrypted: Boolean, + ): Result = Result.success(Outgoing(plaintext = content, wire = content, isSealed = false)) + } +} + +data class Outgoing( + val plaintext: List, + val wire: List, + val isSealed: Boolean, +) { + + /** The server's copy of this message as it should be stored: with the plaintext it was sent from. */ + fun echo(serverMessage: ChatMessage): ChatMessage = + if (isSealed) { + serverMessage.copy(content = plaintext, encryption = MessageEncryption.Decrypted) + } else { + serverMessage + } +} + +internal class DmOutgoingEncryption @Inject constructor( + private val policy: E2eePolicy, + private val crypto: ChatContentCrypto, + private val userManager: UserManager, + private val chatController: ChatController, + private val metadataDataSource: ChatMetadataDataSource, + private val memberDataSource: ChatMemberDataSource, +) : OutgoingEncryption { + + override suspend fun prepare( + chatId: ChatId, + content: List, + wasEncrypted: Boolean, + ): Result { + val chat = chat(chatId) + ?: return Result.failure(IllegalStateException("Can't tell whether $chatId encrypts")) + if (!wasEncrypted && !policy.shouldEncrypt(chat)) { + return Result.success(Outgoing(plaintext = content, wire = content, isSealed = false)) + } + + val selfId = userManager.accountId + ?: return Result.failure(IllegalStateException("No account to encrypt from")) + val peerId = chat.members.firstOrNull { it.userId != selfId }?.userId + ?: return Result.failure(IllegalStateException("No peer in $chatId to encrypt to")) + // A DM message is one Text, or one Reply around Text. Anything else can't be sealed yet, + // and sending it in plaintext would break the chat's promise. + val single = content.singleOrNull() + ?: return Result.failure(IllegalArgumentException("Can't encrypt ${content.size} content items")) + + return crypto.seal(chatId, peerId, single) + .map { sealed -> Outgoing(plaintext = content, wire = listOf(sealed), isSealed = true) } + } + + private suspend fun chat(chatId: ChatId): ChatMetadata? { + metadataDataSource.observeById(chatId).first()?.let { entity -> + val members = memberDataSource.getMembersForChat(chatId) + if (members.isNotEmpty()) return metadataDataSource.toMetadata(entity, members, null) + } + return chatController.getChat(chatId).getOrNull() + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 5dc55342c8..89adb03a41 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -21,6 +21,7 @@ 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.ChatType +import com.flipcash.services.models.chat.ClientMessageId import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.MuteState @@ -39,6 +40,7 @@ import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.PendingMutation import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.OutgoingEncryption import com.flipcash.shared.chat.replacingText import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.MessageLinkPrefetch @@ -71,6 +73,9 @@ import kotlin.time.Clock * 3. [confirmPending][ChatMessageDataSource.confirmPending] replaces the placeholder, * or [failPending][ChatMessageDataSource.failPending] marks it as failed. * + * In an end-to-end encrypted DM the placeholder is still the plaintext; [OutgoingEncryption] + * seals only what goes on the wire, and its echo stores the reply with that plaintext. + * * @see com.flipcash.shared.chat.internal.RealChatCoordinator */ @Singleton @@ -86,6 +91,7 @@ class MessagingDelegate @Inject constructor( private val analytics: FlipcashAnalytics, private val senderResolver: SenderResolver, private val linkPrefetch: MessageLinkPrefetch = MessageLinkPrefetch.None, + private val outgoing: OutgoingEncryption = OutgoingEncryption.None, ) : MessagingOperations { /** @@ -301,23 +307,35 @@ class MessagingDelegate @Inject constructor( senderId = senderId, ) - return messagingController.sendMessage(chatId, payload, clientMessageId) - .onSuccess { serverMessage -> - messageDataSource.confirmPending(chatId, clientMessageId, serverMessage) - advanceReadPointer(chatId, serverMessage.messageId) - - metadataDataSource.updateLastMessageId(chatId, serverMessage.messageId) - metadataDataSource.updateLastActivity(chatId, serverMessage.timestamp.toEpochMilliseconds()) - } - .onFailure { - messageDataSource.failPending(chatId, clientMessageId) - } + return send(chatId, payload, clientMessageId) } override suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List): Result { val clientMessageId = messageDataSource.retryPending(chatId, pendingClientIdHex) - return messagingController.sendMessage(chatId, content, clientMessageId) + // Sealed afresh: the pending row holds plaintext, and the chat may have started or stopped + // encrypting since the first attempt. + return send(chatId, content, clientMessageId) + } + + /** + * Seals [payload] if the chat encrypts, sends it, and settles the pending row. A failure to + * seal fails the row like a failed request, so it can be retried; so does the server refusing + * the ciphertext with `EncryptionNotAllowed`. + */ + private suspend fun send( + chatId: ChatId, + payload: List, + clientMessageId: ClientMessageId, + ): Result { + val prepared = outgoing.prepare(chatId, payload).getOrElse { cause -> + trace(tag = TAG, message = "Couldn't prepare send in $chatId", type = TraceType.Error, error = cause) + messageDataSource.failPending(chatId, clientMessageId) + return Result.failure(cause) + } + + return messagingController.sendMessage(chatId, prepared.wire, clientMessageId) + .map(prepared::echo) .onSuccess { serverMessage -> messageDataSource.confirmPending(chatId, clientMessageId, serverMessage) advanceReadPointer(chatId, serverMessage.messageId) @@ -356,7 +374,14 @@ class MessagingDelegate @Inject constructor( ), ) - return messagingController.editMessage(chatId, messageId, content, expectedSequence) + val prepared = outgoing.prepare(chatId, content, wasEncrypted = stored.encryption != null) + .getOrElse { cause -> + clearMutation(chatId, messageId) + return Result.failure(cause) + } + + return messagingController.editMessage(chatId, messageId, prepared.wire, expectedSequence) + .map(prepared::echo) .reconcile(chatId, messageId) { it is EditMessageError.Conflict } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt new file mode 100644 index 0000000000..5dc300ae58 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt @@ -0,0 +1,326 @@ +package com.flipcash.shared.chat + +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.chat.MessageEncryption +import com.flipcash.services.chat.OpenedContent +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.GetChatError +import com.flipcash.services.models.SendMessageError +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.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.internal.DmOutgoingEncryption +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import com.getcode.chatcipher.ChatEncryptionPolicy +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Instant + +/** + * The send side of end-to-end encrypted DMs: what goes on the wire, what is stored, and what + * happens when sealing can't be done. + */ +class MessagingSendEncryptionTest { + + private val chatId = ChatId(ByteArray(32) { 3 }) + private val selfId: ID = List(16) { 1 } + private val peerId: ID = List(16) { 2 } + private val flipcashId: ID = ChatEncryptionPolicy.FLIPCASH_USER_ID.toList() + private val clientMessageId = ClientMessageId(ByteArray(16) { 9 }) + + private val selfKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val peerKeys = KeyPair(publicKey = ByteArray(32) { 21 }, privateKey = ByteArray(64) { 22 }) + + private class Keys(private val own: KeyPair, private val peerPk: ByteArray) : ChatKeySource { + var peerFails: Throwable? = null + override fun ownKeyPair() = own + override suspend fun peerPublicKey(userId: ID) = + peerFails?.let { Result.failure(it) } ?: Result.success(peerPk) + } + + private val keys = Keys(selfKeys, peerKeys.publicKey) + private val peerCrypto = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) + + private val controller = mockk(relaxed = true) + private val chatController = mockk(relaxed = true) + private val messages = mockk(relaxed = true) + private val metadata = mockk(relaxed = true) + private val members = mockk(relaxed = true) + private val userManager = mockk(relaxed = true).also { + every { it.accountId } returns selfId + } + + private fun chat(type: ChatType = ChatType.CONTACT_DM, useE2ee: Boolean = true, peer: ID = peerId) = + ChatMetadata( + chatId = chatId, + type = type, + members = listOf(member(selfId), member(peer)), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(0), + useE2ee = useE2ee, + ) + + private fun member(id: ID) = ChatMember(userId = id, userProfile = UserProfile.Empty, pointers = emptyList()) + + private fun storedChat(chat: ChatMetadata?) { + val entity = chat?.let { mockk() } + every { metadata.observeById(chatId) } returns flowOf(entity) + coEvery { members.getMembersForChat(chatId) } returns chat?.members.orEmpty() + if (chat != null) every { metadata.toMetadata(any(), any(), any()) } returns chat + } + + private fun serverCopy(content: List, messageId: Long = 10) = ChatMessage( + messageId = messageId, + senderId = selfId, + content = content, + timestamp = Instant.fromEpochSeconds(2_000), + unreadSeq = 0, + eventSequence = 5, + isFromSelf = true, + ) + + private val delegate by lazy { + MessagingDelegate( + chatController = chatController, + messagingController = controller, + 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 = DmOutgoingEncryption( + policy = E2eePolicy(), + crypto = ChatContentCrypto(FakeChatCipher, keys), + userManager = userManager, + chatController = chatController, + metadataDataSource = metadata, + memberDataSource = members, + ), + ) + } + + /** Captures what the send put on the wire and answers with the server's copy of it. */ + private fun answerSends(): MutableList> { + val wires = mutableListOf>() + coEvery { messages.insertPending(chatId, any(), selfId) } answers { + PendingMessage(serverCopy(secondArg()), clientMessageId) + } + coEvery { messages.retryPending(chatId, any()) } returns clientMessageId + coEvery { controller.sendMessage(chatId, any(), any()) } answers { + wires += secondArg>() + Result.success(serverCopy(secondArg())) + } + return wires + } + + private suspend fun openAsPeer(wire: List): MessageContent { + val sealed = assertIs(wire.single()) + val opened = peerCrypto.open(chatId, selfId = peerId, peerId = selfId, senderId = selfId, encrypted = sealed) + return assertIs(opened).content + } + + @Test + fun `text in an encrypted DM goes out sealed and is stored as the plaintext`() = runTest { + storedChat(chat()) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello", replyToMessageId = null).getOrThrow() + + assertEquals(MessageContent.Text("hello"), openAsPeer(wires.single())) + coVerify { messages.insertPending(chatId, listOf(MessageContent.Text("hello")), selfId) } + val stored = slot() + coVerify { messages.confirmPending(chatId, clientMessageId, capture(stored)) } + assertEquals(listOf(MessageContent.Text("hello")), stored.captured.content) + assertEquals(MessageEncryption.Decrypted, stored.captured.encryption) + } + + @Test + fun `a reply is sealed whole, quote id and all`() = runTest { + storedChat(chat()) + val wires = answerSends() + + delegate.sendMessage(chatId, "yes", replyToMessageId = 4).getOrThrow() + + assertEquals( + MessageContent.Reply(repliedMessageId = 4, content = listOf(MessageContent.Text("yes"))), + openAsPeer(wires.single()), + ) + } + + @Test + fun `a DM without the flag goes out in plaintext`() = runTest { + storedChat(chat(useE2ee = false)) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello", replyToMessageId = null).getOrThrow() + + assertEquals(listOf(MessageContent.Text("hello")), wires.single()) + val stored = slot() + coVerify { messages.confirmPending(chatId, clientMessageId, capture(stored)) } + assertEquals(null, stored.captured.encryption) + } + + @Test + fun `the flipcash chat never encrypts`() = runTest { + storedChat(chat(peer = flipcashId)) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello", replyToMessageId = null).getOrThrow() + + assertEquals(listOf(MessageContent.Text("hello")), wires.single()) + } + + @Test + fun `a group never encrypts`() = runTest { + storedChat(chat(type = ChatType.GROUP)) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello", replyToMessageId = null).getOrThrow() + + assertEquals(listOf(MessageContent.Text("hello")), wires.single()) + } + + @Test + fun `a chat that isn't stored is read from the server`() = runTest { + storedChat(null) + coEvery { chatController.getChat(chatId, any()) } returns Result.success(chat()) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello", replyToMessageId = null).getOrThrow() + + assertEquals(MessageContent.Text("hello"), openAsPeer(wires.single())) + } + + @Test + fun `a chat that can't be read fails the send instead of sending plaintext`() = runTest { + storedChat(null) + coEvery { chatController.getChat(chatId, any()) } returns Result.failure(GetChatError.Other()) + answerSends() + + val result = delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + assertEquals(true, result.isFailure) + coVerify(exactly = 0) { controller.sendMessage(any(), any(), any()) } + coVerify { messages.failPending(chatId, clientMessageId) } + } + + @Test + fun `a failed key fetch fails the send so it can be retried`() = runTest { + storedChat(chat()) + keys.peerFails = IOException("offline") + answerSends() + + val result = delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + assertIs(result.exceptionOrNull()) + coVerify(exactly = 0) { controller.sendMessage(any(), any(), any()) } + coVerify { messages.failPending(chatId, clientMessageId) } + } + + @Test + fun `EncryptionNotAllowed fails the send so it can be retried`() = runTest { + storedChat(chat()) + answerSends() + coEvery { controller.sendMessage(chatId, any(), any()) } returns + Result.failure(SendMessageError.EncryptionNotAllowed()) + + val result = delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + assertIs(result.exceptionOrNull()) + coVerify { messages.failPending(chatId, clientMessageId) } + } + + @Test + fun `a retry seals the stored plaintext again`() = runTest { + storedChat(chat()) + val wires = answerSends() + + delegate.retryMessage(chatId, "09", listOf(MessageContent.Text("again"))).getOrThrow() + + assertEquals(MessageContent.Text("again"), openAsPeer(wires.single())) + val stored = slot() + coVerify { messages.confirmPending(chatId, clientMessageId, capture(stored)) } + assertEquals(listOf(MessageContent.Text("again")), stored.captured.content) + } + + private fun answerEdits(original: ChatMessage): MutableList> { + val wires = mutableListOf>() + coEvery { messages.getMessage(chatId, original.messageId) } returns original + coEvery { controller.editMessage(chatId, original.messageId, any(), original.eventSequence) } answers { + wires += thirdArg>() + Result.success(original.copy(content = thirdArg(), eventSequence = original.eventSequence + 1)) + } + return wires + } + + @Test + fun `an encrypted message stays encrypted when edited after the chat stops encrypting`() = runTest { + storedChat(chat(useE2ee = false)) + val original = serverCopy(listOf(MessageContent.Text("before"))) + .copy(encryption = MessageEncryption.Decrypted) + val wires = answerEdits(original) + + delegate.editMessage(chatId, original.messageId, "after").getOrThrow() + + assertEquals(MessageContent.Text("after"), openAsPeer(wires.single())) + val stored = slot>() + coVerify { messages.upsert(chatId, capture(stored)) } + assertEquals(listOf(MessageContent.Text("after")), stored.captured.single().content) + assertEquals(MessageEncryption.Decrypted, stored.captured.single().encryption) + } + + @Test + fun `a plaintext message is encrypted when edited in a chat that now encrypts`() = runTest { + storedChat(chat()) + val original = serverCopy(listOf(MessageContent.Text("before"))) + val wires = answerEdits(original) + + delegate.editMessage(chatId, original.messageId, "after").getOrThrow() + + assertEquals(MessageContent.Text("after"), openAsPeer(wires.single())) + } + + @Test + fun `an edit that can't be sealed is not sent and its overlay comes down`() = runTest { + storedChat(chat()) + keys.peerFails = IOException("offline") + val original = serverCopy(listOf(MessageContent.Text("before"))) + answerEdits(original) + + val result = delegate.editMessage(chatId, original.messageId, "after") + + assertIs(result.exceptionOrNull()) + coVerify(exactly = 0) { controller.editMessage(any(), any(), any(), any()) } + assertEquals(emptyMap(), delegate.observePendingMutations(chatId).first()) + } +} diff --git a/services/flipcash/build.gradle.kts b/services/flipcash/build.gradle.kts index a900cad47d..87d42fe9bd 100644 --- a/services/flipcash/build.gradle.kts +++ b/services/flipcash/build.gradle.kts @@ -21,6 +21,10 @@ android { buildFeatures { buildConfig = true } + + testFixtures { + enable = true + } } dependencies { @@ -28,7 +32,9 @@ dependencies { api(project(":libs:network:jwt")) api(project(":services:opencode")) implementation(project(":ui:resources")) - implementation(project(":libs:encryption:chat-cipher")) + // `api`: ChatContentCrypto and ChatKeySource name ChatCipher and its KeyPair. + api(project(":libs:encryption:chat-cipher")) + testFixturesImplementation(project(":libs:encryption:chat-cipher")) implementation(libs.javax.inject) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt index 8981935f69..967774e60c 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt @@ -3,9 +3,6 @@ package com.flipcash.services.chat import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MessageContent -import com.getcode.chatcipher.ChatCipher -import com.getcode.chatcipher.ChatCipherException -import com.getcode.chatcipher.EncryptedPayload import com.getcode.ed25519kmp.KeyPair import com.getcode.opencode.model.core.ID import kotlinx.coroutines.test.runTest @@ -160,53 +157,3 @@ class ChatContentCryptoTest { return MessageContent.Encrypted(ChatContentCrypto.SCHEME_X25519_XCHACHA20POLY1305, payload.nonce, payload.ciphertext) } } - -/** - * Stands in for [com.getcode.chatcipher.DefaultChatCipher], whose libsodium binding can't load on - * a JVM host. It keeps the properties callers depend on: both members derive the same chat key, - * and opening fails unless the key, the sender/recipient order and the bytes all match. - */ -internal object FakeChatCipher : ChatCipher { - private const val HEADER = 32 * 3 - - override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray = - ByteArray(32) { i -> (ownKeyPair.publicKey[i].toInt() xor peerPublicKey[i].toInt() xor chatId[i].toInt()).toByte() } - - override fun encrypt( - content: ByteArray, - chatKey: ByteArray, - senderPk: ByteArray, - recipientPk: ByteArray, - chatId: ByteArray, - ) = EncryptedPayload(nonce = ByteArray(24), ciphertext = chatKey + senderPk + recipientPk + content) - - override fun decrypt( - payload: EncryptedPayload, - chatKey: ByteArray, - senderPk: ByteArray, - recipientPk: ByteArray, - chatId: ByteArray, - ): ByteArray { - val header = payload.ciphertext.copyOfRange(0, HEADER) - if (!header.contentEquals(chatKey + senderPk + recipientPk)) throw ChatCipherException("authentication failed") - return payload.ciphertext.copyOfRange(HEADER, payload.ciphertext.size) - } - - override fun encryptBlob( - image: ByteArray, - chatKey: ByteArray, - senderPk: ByteArray, - recipientPk: ByteArray, - chatId: ByteArray, - blobId: ByteArray, - ): ByteArray = throw UnsupportedOperationException() - - override fun decryptBlob( - blob: ByteArray, - chatKey: ByteArray, - senderPk: ByteArray, - recipientPk: ByteArray, - chatId: ByteArray, - blobId: ByteArray, - ): ByteArray = throw UnsupportedOperationException() -} diff --git a/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt new file mode 100644 index 0000000000..88f038e2a4 --- /dev/null +++ b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt @@ -0,0 +1,56 @@ +package com.flipcash.services.chat + +import com.getcode.chatcipher.ChatCipher +import com.getcode.chatcipher.ChatCipherException +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.ed25519kmp.KeyPair + +/** + * Stands in for [com.getcode.chatcipher.DefaultChatCipher], whose libsodium binding can't load on + * a JVM host. It keeps the properties callers depend on: both members derive the same chat key, + * and opening fails unless the key, the sender/recipient order and the bytes all match. + */ +object FakeChatCipher : ChatCipher { + private const val HEADER = 32 * 3 + + override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray = + ByteArray(32) { i -> (ownKeyPair.publicKey[i].toInt() xor peerPublicKey[i].toInt() xor chatId[i].toInt()).toByte() } + + override fun encrypt( + content: ByteArray, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + ) = EncryptedPayload(nonce = ByteArray(24), ciphertext = chatKey + senderPk + recipientPk + content) + + override fun decrypt( + payload: EncryptedPayload, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + ): ByteArray { + val header = payload.ciphertext.copyOfRange(0, HEADER) + if (!header.contentEquals(chatKey + senderPk + recipientPk)) throw ChatCipherException("authentication failed") + return payload.ciphertext.copyOfRange(HEADER, payload.ciphertext.size) + } + + override fun encryptBlob( + image: ByteArray, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + blobId: ByteArray, + ): ByteArray = throw UnsupportedOperationException() + + override fun decryptBlob( + blob: ByteArray, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + blobId: ByteArray, + ): ByteArray = throw UnsupportedOperationException() +} From c421b73f38a33c8ad5bcc928b44686fd75f256a8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 14:05:10 -0400 Subject: [PATCH 10/14] feat(chat): open encrypted DM messages on receive and store the plaintext Incoming messages are opened in ChatMessageDataSource before they're written, so the transcript, quotes and chat-list preview all read plaintext from Room. The ciphertext is kept beside it in two new columns, ciphertext_json and encryption_state (migration 39 to 40), which lets a later copy of the same message reuse the stored plaintext instead of opening it again. A message that didn't open shows the "can't be displayed" bubble with a hint picked by cause: an unknown scheme or type asks for an update; a failed authentication asks the sender by first name to resend, or the viewer to send their own again. A failed peer key fetch is not a decrypt failure: the row is stored KEY_PENDING, hidden, and opened again on the chat's next write and at login. Rows stored encrypted before this version are marked KEY_PENDING by the migration. --- .../app/messenger/internal/ChatViewModel.kt | 10 + .../shared/chat/models/ChatListItem.kt | 6 + .../flipcash/shared/chat/ui/MessageBubble.kt | 7 +- .../shared/chat/ui/UndecryptableBubble.kt | 35 +- .../shared/chat/ui/UndecryptableHintTest.kt | 51 + .../chat/internal/OutgoingEncryption.kt | 10 +- .../chat/internal/RealChatCoordinator.kt | 4 + .../internal/delegates/MessagingDelegate.kt | 5 + .../chat/MessagingSendEncryptionTest.kt | 6 +- .../40.json | 936 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 20 +- .../app/persistence/dao/ChatMessageDao.kt | 42 +- .../persistence/entities/ChatMessageEntity.kt | 25 + .../persistence/EncryptedRowMigrationTest.kt | 34 + .../app/persistence/dao/ChatMessageDaoTest.kt | 66 ++ .../persistence/sources/build.gradle.kts | 1 + .../sources/ChatMessageDataSource.kt | 75 +- .../sources/IncomingMessageOpener.kt | 86 ++ .../sources/mapper/chat/ChatEntityMapper.kt | 37 + .../sources/IncomingMessageOpenerTest.kt | 155 +++ .../mapper/chat/ChatEntityMapperTest.kt | 53 + .../services/chat/MessageEncryption.kt | 6 +- 22 files changed, 1649 insertions(+), 21 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UndecryptableHintTest.kt create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/40.json create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/EncryptedRowMigrationTest.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt create mode 100644 apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt 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 6d89499dec..ffb5f35b4d 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 @@ -104,6 +104,8 @@ import com.flipcash.shared.chat.reactions.ReactionStripComposer import com.flipcash.shared.chat.reactions.SelfReaction import com.flipcash.shared.chat.readOnly import com.flipcash.shared.chat.resolveCapabilities +import com.flipcash.shared.chat.ui.UndecryptableHint +import com.flipcash.shared.chat.ui.undecryptableHint import com.flipcash.shared.chat.ui.detectMentions import com.flipcash.shared.chat.ui.detectUrls import com.flipcash.shared.chat.ui.linkableText @@ -952,6 +954,14 @@ internal class ChatViewModel @Inject constructor( reactionPills = storedReactions.pills, selfReactions = storedReactions.selfReactions, canReact = canReact(message), + undecryptableHint = undecryptableHint( + encryption = message.encryption, + isFromSelf = message.isFromSelf, + // Only a DM is encrypted, so the sender of an incoming one is the + // participant. + senderName = stateFlow.value.participant?.name + ?: resources.getString(R.string.title_unnamedUser), + ) ?: UndecryptableHint.UpdateApp, ) // A carded link takes a row of its own, with the prose either side of it // on rows above and below. Reversed because the list is: this page runs diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 9ae6456c18..e5137edd36 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -5,6 +5,7 @@ import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.reactions.ReactionPill import com.flipcash.shared.chat.reactions.SelfReaction import com.flipcash.shared.chat.ui.DetectedMention +import com.flipcash.shared.chat.ui.UndecryptableHint import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -125,6 +126,11 @@ sealed interface ChatListItem { * pending send are excluded. See `com.flipcash.shared.chat.canReact`. */ val canReact: Boolean = false, + /** + * The line under the bubble when [content] is ciphertext that didn't open. See + * [undecryptableHint][com.flipcash.shared.chat.ui.undecryptableHint]. + */ + val undecryptableHint: UndecryptableHint = UndecryptableHint.UpdateApp, ) : ChatListItem { /** * Who this bubble is attributed to, for grouping. [senderId] is the answer whenever the 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 f0f53a1bda..9d9c9bdea1 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,12 +260,11 @@ fun ContentBubble( attention = attention, ) - // 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. + // Ciphertext reaches a bubble only when it didn't open; an opened message carries + // its plaintext content instead. is MessageContent.Encrypted -> UndecryptableBubble( modifier = modifier, - hint = UndecryptableHint.UpdateApp, + hint = item.undecryptableHint, ) // 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 index 7aa572e2d5..7ca8bd7f6d 100644 --- 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 @@ -25,13 +25,13 @@ 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.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.UndecryptableReason 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. + * What the line under an [UndecryptableBubble] tells the reader to do about it. Picked by + * [undecryptableHint] from why the message didn't open. */ sealed interface UndecryptableHint { /** A newer version of the app can read it. */ @@ -44,6 +44,33 @@ sealed interface UndecryptableHint { data object TrySendingAgain : UndecryptableHint } +/** + * The hint for a message that arrived encrypted and didn't open, from [encryption]: + * - an unknown scheme or content type, or no recorded outcome, is one a newer client can read; + * - a failed authentication is the sender's to fix, so the viewer is asked to resend their own + * and to ask [senderName], by first name, to resend theirs. + * + * [senderName] is the whole name; its first word is used. Null for an opened message. + */ +fun undecryptableHint( + encryption: MessageEncryption?, + isFromSelf: Boolean, + senderName: String, +): UndecryptableHint? = when (encryption) { + is MessageEncryption.Decrypted, MessageEncryption.KeyPending -> null + is MessageEncryption.Undecryptable -> when (encryption.reason) { + UndecryptableReason.Unsupported -> UndecryptableHint.UpdateApp + UndecryptableReason.Authentication -> if (isFromSelf) { + UndecryptableHint.TrySendingAgain + } else { + UndecryptableHint.AskToResend(senderName.firstName()) + } + } + null -> UndecryptableHint.UpdateApp +} + +private fun String.firstName(): String = trim().substringBefore(' ') + @Composable private fun UndecryptableHint.text(): String = when (this) { UndecryptableHint.UpdateApp -> stringResource(R.string.hint_messageUndecryptable_update) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UndecryptableHintTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UndecryptableHintTest.kt new file mode 100644 index 0000000000..34bdcdea9b --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UndecryptableHintTest.kt @@ -0,0 +1,51 @@ +package com.flipcash.shared.chat.ui + +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.UndecryptableReason +import com.flipcash.services.models.chat.MessageContent +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UndecryptableHintTest { + + private fun hint(encryption: MessageEncryption?, isFromSelf: Boolean = false, senderName: String = "Ada Lovelace") = + undecryptableHint(encryption, isFromSelf, senderName) + + @Test + fun `an unknown scheme or type asks for an update`() { + assertEquals( + UndecryptableHint.UpdateApp, + hint(MessageEncryption.Undecryptable(UndecryptableReason.Unsupported)), + ) + } + + @Test + fun `ciphertext with no recorded outcome asks for an update`() { + assertEquals(UndecryptableHint.UpdateApp, hint(null)) + } + + @Test + fun `a failed authentication from the peer asks them, by first name, to resend`() { + assertEquals( + UndecryptableHint.AskToResend("Ada"), + hint(MessageEncryption.Undecryptable(UndecryptableReason.Authentication)), + ) + } + + @Test + fun `a failed authentication on the viewer's own message asks them to send again`() { + assertEquals( + UndecryptableHint.TrySendingAgain, + hint(MessageEncryption.Undecryptable(UndecryptableReason.Authentication), isFromSelf = true), + ) + } + + @Test + fun `an opened or pending message has no hint`() { + val sealed = MessageContent.Encrypted(scheme = 1, nonce = ByteArray(24), ciphertext = ByteArray(1)) + + assertNull(hint(MessageEncryption.Decrypted(sealed))) + assertNull(hint(MessageEncryption.KeyPending)) + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt index 581655ad90..ad81279141 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt @@ -57,10 +57,12 @@ data class Outgoing( /** The server's copy of this message as it should be stored: with the plaintext it was sent from. */ fun echo(serverMessage: ChatMessage): ChatMessage = - if (isSealed) { - serverMessage.copy(content = plaintext, encryption = MessageEncryption.Decrypted) - } else { - serverMessage + when (val sealed = wire.singleOrNull()) { + is MessageContent.Encrypted if isSealed -> serverMessage.copy( + content = plaintext, + encryption = MessageEncryption.Decrypted(sealed), + ) + else -> serverMessage } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index dc929f7392..8462c33412 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -154,6 +154,10 @@ class RealChatCoordinator @Inject constructor( // answers delays the sync rather than cancelling it. withTimeoutOrNull(FEED_READ_WAIT) { stateHolder.state.first { it.feed != null } } syncFeeds() + // Encrypted messages a previous session couldn't open, and any stored before this version + // could open them at all. A chat's own writes retry it too; this reaches the chats that + // don't get one. + scope.launch { messagingDelegate.openKeyPending() } eventStreamDelegate.open() eventStreamDelegate.startHeartbeat { syncFeeds() } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 89adb03a41..ac2f69aee4 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -583,6 +583,11 @@ class MessagingDelegate @Inject constructor( messageDataSource.failInterruptedSends() } + /** Opens the encrypted messages still waiting on a key, in every chat. */ + internal suspend fun openKeyPending() { + messageDataSource.reopenAllKeyPending() + } + internal suspend fun clear() { pendingMutations.value = emptyMap() metadataDataSource.clear() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt index 5dc300ae58..f8435de1d9 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt @@ -161,7 +161,7 @@ class MessagingSendEncryptionTest { val stored = slot() coVerify { messages.confirmPending(chatId, clientMessageId, capture(stored)) } assertEquals(listOf(MessageContent.Text("hello")), stored.captured.content) - assertEquals(MessageEncryption.Decrypted, stored.captured.encryption) + assertEquals(MessageEncryption.Decrypted(wires.single().single() as MessageContent.Encrypted), stored.captured.encryption) } @Test @@ -287,7 +287,7 @@ class MessagingSendEncryptionTest { fun `an encrypted message stays encrypted when edited after the chat stops encrypting`() = runTest { storedChat(chat(useE2ee = false)) val original = serverCopy(listOf(MessageContent.Text("before"))) - .copy(encryption = MessageEncryption.Decrypted) + .copy(encryption = MessageEncryption.Decrypted(MessageContent.Encrypted(1, ByteArray(24), ByteArray(8)))) val wires = answerEdits(original) delegate.editMessage(chatId, original.messageId, "after").getOrThrow() @@ -296,7 +296,7 @@ class MessagingSendEncryptionTest { val stored = slot>() coVerify { messages.upsert(chatId, capture(stored)) } assertEquals(listOf(MessageContent.Text("after")), stored.captured.single().content) - assertEquals(MessageEncryption.Decrypted, stored.captured.single().encryption) + assertEquals(MessageEncryption.Decrypted(wires.single().single() as MessageContent.Encrypted), stored.captured.single().encryption) } @Test diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/40.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/40.json new file mode 100644 index 0000000000..fef8564878 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/40.json @@ -0,0 +1,936 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "23409d5095e4f1a6a1b02274376202ca", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '23409d5095e4f1a6a1b02274376202ca')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 8919fd0399..dc60110d2b 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -110,8 +110,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 36, to = 37, spec = FlipcashDatabase.Migration36To37::class), AutoMigration(from = 37, to = 38), // chat_metadata.creator_hex (nullable), use_e2ee (default 0) AutoMigration(from = 38, to = 39), // link_previews table + AutoMigration(from = 39, to = 40, spec = FlipcashDatabase.Migration39To40::class), ], - version = 39, + version = 40, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -278,6 +279,23 @@ abstract class FlipcashDatabase : RoomDatabase() { } } + /** + * Adds `chat_messages.ciphertext_json` and `encryption_state`. A row stored before this version + * with encrypted content was never opened, so it's marked for opening with its ciphertext + * copied across, the same as a row whose key fetch failed. + */ + class Migration39To40 : AutoMigrationSpec { + override fun onPostMigrate(connection: SQLiteConnection) { + connection.execSQL(MARK_ENCRYPTED_FOR_OPENING) + } + + companion object { + const val MARK_ENCRYPTED_FOR_OPENING = + "UPDATE chat_messages SET encryption_state = 'KEY_PENDING', ciphertext_json = content_json " + + "WHERE content_json LIKE '[{\"type\":\"encrypted\"%'" + } + } + companion object { /** diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 49ef43d42c..9d30d931a2 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -8,13 +8,14 @@ import androidx.room.Query import androidx.room.Transaction import com.flipcash.app.persistence.converters.mergeReactionsJson import com.flipcash.app.persistence.entities.ChatMessageEntity +import com.flipcash.app.persistence.entities.EncryptionState import com.flipcash.app.persistence.entities.MessageStatus import kotlinx.coroutines.flow.Flow @Dao interface ChatMessageDao { - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms ASC, message_id ASC") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') ORDER BY timestamp_epoch_ms ASC, message_id ASC") fun observeMessages(chatIdHex: String): Flow> /** @@ -22,7 +23,7 @@ interface ChatMessageDao { * sent in a burst, or stamped from one server clock read, share a millisecond. A paged read * re-queries per page, so an unstable order there duplicates or skips a row across the seam. */ - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC, message_id DESC") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') ORDER BY timestamp_epoch_ms DESC, message_id DESC") fun observeMessagesPaged(chatIdHex: String): PagingSource /** @@ -44,7 +45,7 @@ interface ChatMessageDao { * the newest id including tombstones, or a delete would regress the read pointer and leave the * chat unread forever. */ - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 ORDER BY timestamp_epoch_ms DESC, message_id DESC LIMIT 1") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') ORDER BY timestamp_epoch_ms DESC, message_id DESC LIMIT 1") suspend fun getLatestVisible(chatIdHex: String): ChatMessageEntity? /** @@ -58,6 +59,7 @@ interface ChatMessageDao { "SELECT * FROM chat_messages WHERE rowid IN (" + "SELECT (SELECT m.rowid FROM chat_messages m " + "WHERE m.chat_id_hex = c.chat_id_hex AND m.is_deleted = 0 " + + "AND (m.encryption_state IS NULL OR m.encryption_state != 'KEY_PENDING') " + "ORDER BY m.timestamp_epoch_ms DESC, m.message_id DESC LIMIT 1) " + "FROM (SELECT DISTINCT chat_id_hex FROM chat_messages) c)" ) @@ -129,6 +131,28 @@ interface ChatMessageDao { @Query("SELECT COUNT(*) FROM chat_messages WHERE chat_id_hex = :chatIdHex AND timestamp_epoch_ms > :timestampEpochMs") suspend fun countNewerThan(chatIdHex: String, timestampEpochMs: Long): Int + /** Rows in [chatIdHex] still waiting on a key to be opened; see [EncryptionState.KEY_PENDING]. */ + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND encryption_state = 'KEY_PENDING'") + suspend fun getKeyPending(chatIdHex: String): List + + @Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE chat_id_hex = :chatIdHex AND encryption_state = 'KEY_PENDING')") + suspend fun hasKeyPending(chatIdHex: String): Boolean + + @Query("SELECT DISTINCT chat_id_hex FROM chat_messages WHERE encryption_state = 'KEY_PENDING'") + suspend fun chatsWithKeyPending(): List + + /** + * The oldest message in [chatIdHex] that arrived end-to-end encrypted and is shown, opened or + * not. The transcript's Encrypted marker sits above it. Ordered as the transcript is. + */ + @Query( + "SELECT message_id FROM chat_messages " + + "WHERE chat_id_hex = :chatIdHex AND encryption_state IS NOT NULL " + + "AND encryption_state != 'KEY_PENDING' " + + "ORDER BY timestamp_epoch_ms ASC, message_id ASC LIMIT 1" + ) + fun observeOldestEncrypted(chatIdHex: String): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: ChatMessageEntity) @@ -292,6 +316,18 @@ interface ChatMessageDao { newUnreadSeq = serverMessage.unreadSeq, newEventSequence = serverMessage.eventSequence, ) + // An encrypted send went out as ciphertext; the row keeps its plaintext and gains the + // ciphertext beside it. Written as a row rather than in the UPDATE above, since Room would + // expand a list parameter into an IN clause. + if (serverMessage.encryptionState != null) { + val confirmed = getByClientId(chatIdHex, clientIdHex) ?: return + insert( + confirmed.copy( + ciphertextJson = serverMessage.ciphertextJson, + encryptionState = serverMessage.encryptionState, + ) + ) + } } @Transaction diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt index df9c27e0a0..eca8b8f4be 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt @@ -11,6 +11,24 @@ enum class MessageStatus { FAILED, } +/** How an end-to-end encrypted row stands on this device. Null on a row sent in plaintext. */ +enum class EncryptionState { + /** [ChatMessageEntity.contentJson] is the plaintext. */ + DECRYPTED, + + /** + * A key fetch failed, so the row hasn't been opened yet. Hidden from the transcript and the + * chat list until it is. + */ + KEY_PENDING, + + /** An unknown scheme, or a plaintext type this client doesn't render. */ + UNSUPPORTED, + + /** The ciphertext failed authentication. */ + AUTH_FAILED, +} + /** * The transcript reads this table one page at a time, ordered newest-first within a chat, and the * composite primary key `(chat_id_hex, message_id)` does not serve that order. Without the index @@ -47,4 +65,11 @@ data class ChatMessageEntity( * someone sends a message whose text happens to contain it. */ @ColumnInfo(name = "is_deleted", defaultValue = "0") val isDeleted: Boolean = false, + /** + * The message as it arrived, when it arrived end-to-end encrypted: one serialized + * `Encrypted`. Kept beside the plaintext in [contentJson] so a later copy of the same message + * is recognised without opening it again. + */ + @ColumnInfo(name = "ciphertext_json") val ciphertextJson: List? = null, + @ColumnInfo(name = "encryption_state") val encryptionState: EncryptionState? = null, ) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/EncryptedRowMigrationTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/EncryptedRowMigrationTest.kt new file mode 100644 index 0000000000..e4a68646b4 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/EncryptedRowMigrationTest.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.persistence + +import com.flipcash.app.persistence.converters.ChatTypeConverters +import com.flipcash.app.persistence.converters.MessageContentSerialized +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [FlipcashDatabase.Migration39To40] finds the encrypted rows to mark by a `LIKE` on the stored + * JSON, so it depends on how the converter writes an encrypted message. + */ +class EncryptedRowMigrationTest { + + private val converters = ChatTypeConverters() + private val prefix = FlipcashDatabase.Migration39To40.MARK_ENCRYPTED_FOR_OPENING + .substringAfter("LIKE '").substringBefore("%'") + + @Test + fun `an encrypted message matches the migration's pattern`() { + val json = converters.toMessageContentList( + listOf(MessageContentSerialized.Encrypted(scheme = 1, nonce = "AA", ciphertext = "BB")) + )!! + + assertTrue(json.startsWith(prefix), json) + } + + @Test + fun `a text message doesn't`() { + val json = converters.toMessageContentList(listOf(MessageContentSerialized.Text("encrypted")))!! + + assertFalse(json.startsWith(prefix), json) + } +} diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index a207be8692..f95a4471fd 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -9,7 +9,9 @@ import com.flipcash.app.persistence.converters.EmojiReactionSerialized import com.flipcash.app.persistence.converters.MessageContentSerialized import com.flipcash.app.persistence.converters.ReactionSummarySerialized import com.flipcash.app.persistence.entities.ChatMessageEntity +import com.flipcash.app.persistence.entities.EncryptionState import com.flipcash.app.persistence.entities.MessageStatus +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import org.junit.After @@ -512,6 +514,70 @@ class ChatMessageDaoTest { } /** SQLite's running count of rows written on this connection, triggers included. */ + // region end-to-end encryption + + private val cipherJson = listOf(MessageContentSerialized.Encrypted(scheme = 1, nonce = "AA", ciphertext = "BB")) + + private fun encrypted(messageId: Long, state: EncryptionState) = ChatMessageEntity( + chatIdHex = CHAT_HEX, + messageId = messageId, + senderIdHex = SENDER_HEX, + contentJson = if (state == EncryptionState.DECRYPTED) listOf(MessageContentSerialized.Text("hi")) else cipherJson, + timestampEpochMs = messageId * 1_000, + unreadSeq = messageId, + ciphertextJson = cipherJson, + encryptionState = state, + ) + + @Test + fun `a message waiting on its key is hidden from the preview`() = runTest { + dao.upsert(listOf(text(1, "plain"), encrypted(2, EncryptionState.KEY_PENDING))) + + assertEquals(1L, dao.getLatestVisible(CHAT_HEX)?.messageId) + assertEquals(listOf(1L), dao.getLatestVisibleForAllChats().map { it.messageId }) + } + + @Test + fun `waiting messages are listed for reopening`() = runTest { + dao.upsert(listOf(text(1, "plain"), encrypted(2, EncryptionState.KEY_PENDING))) + + assertEquals(true, dao.hasKeyPending(CHAT_HEX)) + assertEquals(listOf(2L), dao.getKeyPending(CHAT_HEX).map { it.messageId }) + assertEquals(listOf(CHAT_HEX), dao.chatsWithKeyPending()) + } + + @Test + fun `the oldest encrypted message skips plaintext and waiting rows`() = runTest { + dao.upsert( + listOf( + text(1, "plain"), + encrypted(2, EncryptionState.KEY_PENDING), + encrypted(3, EncryptionState.AUTH_FAILED), + encrypted(4, EncryptionState.DECRYPTED), + ) + ) + + assertEquals(3L, dao.observeOldestEncrypted(CHAT_HEX).first()) + } + + @Test + fun `confirming an encrypted send keeps its plaintext and gains the ciphertext`() = runTest { + dao.upsert(pending("hello")) + + dao.confirmPendingMessage( + CHAT_HEX, + CLIENT_HEX, + text(7, "hello").copy(ciphertextJson = cipherJson, encryptionState = EncryptionState.DECRYPTED), + ) + + val stored = dao.getMessage(CHAT_HEX, 7)!! + assertEquals(listOf(MessageContentSerialized.Text("hello")), stored.contentJson) + assertEquals(cipherJson, stored.ciphertextJson) + assertEquals(EncryptionState.DECRYPTED, stored.encryptionState) + } + + // endregion + private fun totalChanges(): Long = db.openHelper.writableDatabase.query("SELECT total_changes()").use { cursor -> cursor.moveToFirst() diff --git a/apps/flipcash/shared/persistence/sources/build.gradle.kts b/apps/flipcash/shared/persistence/sources/build.gradle.kts index 7f09ed6ea6..744be06cbf 100644 --- a/apps/flipcash/shared/persistence/sources/build.gradle.kts +++ b/apps/flipcash/shared/persistence/sources/build.gradle.kts @@ -16,6 +16,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":services:flipcash"))) implementation(libs.bundles.kotlinx.serialization) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index 29483e2bab..a1453d65ae 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -6,6 +6,7 @@ import com.flipcash.app.persistence.FlipcashDatabase import com.flipcash.app.persistence.dao.ChatMessageDao import com.flipcash.app.persistence.entities.ChatMessageEntity import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper +import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata @@ -39,6 +40,7 @@ data class PendingMessage( class ChatMessageDataSource @Inject constructor( private val mapper: ChatEntityMapper, private val userManager: UserManager, + private val opener: IncomingMessageOpener, ) : PagingDataSource, Int, ChatMessageEntity> { private val db: FlipcashDatabase? @@ -196,6 +198,70 @@ class ChatMessageDataSource @Inject constructor( suspend fun upsert(chatId: ChatId, messages: List) { val hex = mapper.chatIdHex(chatId) + val opened = openEncrypted(chatId, hex, messages) + write(hex, opened) + // A write that got a key is the next chance to open what an earlier one couldn't. + if (opened.none { it.encryption == MessageEncryption.KeyPending }) { + reopenKeyPending(chatId) + } + } + + /** + * Opens the messages in [chatId] stored [MessageEncryption.KeyPending]: those whose key fetch + * failed, or that arrived before the other member of the DM was stored. Cheap when there are + * none. Called on each write to the chat, when the chat is opened, and on a push for it. + */ + suspend fun reopenKeyPending(chatId: ChatId) { + val dao = db?.chatMessageDao() ?: return + val hex = mapper.chatIdHex(chatId) + if (!dao.hasKeyPending(hex)) return + val selfId = userManager.accountId + val peerId = peerOf(hex, selfId) + val reopened = dao.getKeyPending(hex).map { entity -> + opener.reopen(chatId, selfId, peerId, mapper.toMessage(entity)) + } + if (reopened.all { it.encryption == MessageEncryption.KeyPending }) return + dao.upsert(reopened.map { mapper.toEntity(hex, it) }) + } + + /** [reopenKeyPending] for every chat that has a message waiting. */ + suspend fun reopenAllKeyPending() { + val dao = db?.chatMessageDao() ?: return + for (hex in dao.chatsWithKeyPending()) reopenKeyPending(mapper.chatIdFromHex(hex)) + } + + /** + * The id of the oldest end-to-end encrypted message in [chatId] the transcript shows, opened + * or not; the Encrypted marker sits above it. + */ + fun observeOldestEncryptedMessageId(chatId: ChatId): Flow = + db?.chatMessageDao()?.observeOldestEncrypted(mapper.chatIdHex(chatId)) ?: flowOf(null) + + private suspend fun openEncrypted(chatId: ChatId, hex: String, messages: List): List { + if (messages.none { it.encryption == null && it.content.singleOrNull() is MessageContent.Encrypted }) { + return messages + } + val dao = db?.chatMessageDao() ?: return messages + val selfId = userManager.accountId + return opener.open( + chatId = chatId, + selfId = selfId, + peerId = peerOf(hex, selfId), + messages = messages, + stored = { messageId -> dao.getMessage(hex, messageId)?.let(mapper::toMessage) }, + ) + } + + /** The DM member who isn't the viewer, when the chat's members are stored. */ + private suspend fun peerOf(chatIdHex: String, selfId: ID?): ID? { + val selfHex = selfId?.hexEncodedString() ?: return null + return db?.chatMemberDao()?.getMembersForChat(chatIdHex) + ?.map { it.member.userIdHex } + ?.singleOrNull { it != selfHex } + ?.let(mapper::userIdFromHex) + } + + private suspend fun write(hex: String, messages: List) { val entities = messages.map { mapper.toEntity(hex, it) } val selfId = userManager.accountId val selfHex = selfId?.hexEncodedString() @@ -229,8 +295,15 @@ class ChatMessageDataSource @Inject constructor( suspend fun upsertAll(messagesByChat: Map>) { val database = db ?: return if (messagesByChat.isEmpty()) return + // Opened before the transaction: opening can fetch a key over the network. + val opened = messagesByChat.mapValues { (chatId, messages) -> + openEncrypted(chatId, mapper.chatIdHex(chatId), messages) + } database.withTransaction { - for ((chatId, messages) in messagesByChat) upsert(chatId, messages) + for ((chatId, messages) in opened) write(mapper.chatIdHex(chatId), messages) + } + for ((chatId, messages) in opened) { + if (messages.none { it.encryption == MessageEncryption.KeyPending }) reopenKeyPending(chatId) } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt new file mode 100644 index 0000000000..da18daaefc --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt @@ -0,0 +1,86 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.OpenedContent +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.core.ID +import javax.inject.Inject + +/** + * Opens the end-to-end encrypted messages in a write before they're stored, so the plaintext is + * stored beside the ciphertext and everything reading the table (transcript, quotes, previews) + * sees plaintext. + */ +class IncomingMessageOpener @Inject constructor( + private val crypto: ChatContentCrypto, +) { + /** + * [messages] with each encrypted one opened and its [ChatMessage.encryption] set. A message + * already stored opened with the same ciphertext takes the stored plaintext instead of being + * opened again. + * + * [peerId] is the other member of the DM when it's stored; a message from the viewer can't be + * opened without it, and is left [MessageEncryption.KeyPending] to be opened once it is. + */ + suspend fun open( + chatId: ChatId, + selfId: ID?, + peerId: ID?, + messages: List, + stored: suspend (messageId: Long) -> ChatMessage?, + ): List = messages.map { message -> + val sealed = message.sealedContent() ?: return@map message + + val storedCopy = stored(message.messageId) + val storedEncryption = storedCopy?.encryption + if (storedEncryption is MessageEncryption.Decrypted && storedEncryption.sealed == sealed) { + return@map message.copy(content = storedCopy.content, encryption = storedEncryption) + } + + open(chatId, selfId, peerId, message, sealed) + } + + /** Opens [message], which is stored [MessageEncryption.KeyPending], or leaves it that way. */ + suspend fun reopen(chatId: ChatId, selfId: ID?, peerId: ID?, message: ChatMessage): ChatMessage { + val sealed = message.content.singleOrNull() as? MessageContent.Encrypted ?: return message + return open(chatId, selfId, peerId, message, sealed) + } + + private suspend fun open( + chatId: ChatId, + selfId: ID?, + peerId: ID?, + message: ChatMessage, + sealed: MessageContent.Encrypted, + ): ChatMessage { + val peer = when (val sender = message.senderId) { + null -> null + selfId -> peerId + else -> sender + } + if (selfId == null || peer == null) { + return message.copy(content = listOf(sealed), encryption = MessageEncryption.KeyPending) + } + + return when (val opened = crypto.open(chatId, selfId, peer, message.senderId, sealed)) { + is OpenedContent.Plaintext -> message.copy( + content = listOf(opened.content), + encryption = MessageEncryption.Decrypted(sealed), + ) + OpenedContent.KeyPending -> message.copy(encryption = MessageEncryption.KeyPending) + is OpenedContent.Undecryptable -> message.copy( + encryption = MessageEncryption.Undecryptable(opened.reason), + ) + } + } + + /** + * The ciphertext of a message as the server sent it. A message that already carries an + * [ChatMessage.encryption] was opened on this device (a confirmed send) and is left alone. + */ + private fun ChatMessage.sealedContent(): MessageContent.Encrypted? = + if (encryption != null) null else content.singleOrNull() as? MessageContent.Encrypted +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 9a312260f8..2fe4560a1f 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -13,10 +13,13 @@ import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMemberWithProfile import com.flipcash.app.persistence.entities.ChatMessageEntity import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.entities.EncryptionState import com.flipcash.app.persistence.entities.MessageStatus import com.flipcash.app.persistence.entities.UserProfileEntity import com.flipcash.app.persistence.entities.toSerialized import com.flipcash.app.persistence.sources.mapper.toDomain +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.UndecryptableReason import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId @@ -179,6 +182,8 @@ class ChatEntityMapper @Inject constructor() { lastEditedTsEpochMs = message.lastEditedTs?.toEpochMilliseconds(), reactionsJson = encodeReactions(message.reactions), isDeleted = message.content.any { it is MessageContent.Deleted }, + ciphertextJson = message.ciphertext()?.let { listOf(it.toSerialized()) }, + encryptionState = message.encryption?.toState(), ) } @@ -198,6 +203,7 @@ class ChatEntityMapper @Inject constructor() { MessageStatus.FAILED -> DeliveryStatus.FAILED }, pendingClientIdHex = entity.pendingClientIdHex, + encryption = entity.toEncryption(), ) } @@ -320,6 +326,8 @@ class ChatEntityMapper @Inject constructor() { fun userIdHex(userId: ID): String = userId.hexEncodedString() + fun userIdFromHex(hex: String): ID = hex.hexToId() + private fun String.hexToByteArray(): ByteArray { val len = length val data = ByteArray(len / 2) @@ -333,6 +341,35 @@ class ChatEntityMapper @Inject constructor() { private fun String.hexToId(): List = hexToByteArray().toList() + /** The ciphertext the message arrived as; its content is the ciphertext unless it was opened. */ + private fun ChatMessage.ciphertext(): MessageContent.Encrypted? = when (val encryption = encryption) { + null -> null + is MessageEncryption.Decrypted -> encryption.sealed + else -> content.singleOrNull() as? MessageContent.Encrypted + } + + private fun MessageEncryption.toState(): EncryptionState = when (this) { + is MessageEncryption.Decrypted -> EncryptionState.DECRYPTED + MessageEncryption.KeyPending -> EncryptionState.KEY_PENDING + is MessageEncryption.Undecryptable -> when (reason) { + UndecryptableReason.Unsupported -> EncryptionState.UNSUPPORTED + UndecryptableReason.Authentication -> EncryptionState.AUTH_FAILED + } + } + + private fun ChatMessageEntity.toEncryption(): MessageEncryption? = when (encryptionState) { + null -> null + EncryptionState.DECRYPTED -> { + val sealed = ciphertextJson?.singleOrNull()?.toDomain() as? MessageContent.Encrypted + // A decrypted row without its ciphertext can't have come from this client; read it as + // plaintext rather than drop it. + sealed?.let(MessageEncryption::Decrypted) + } + EncryptionState.KEY_PENDING -> MessageEncryption.KeyPending + EncryptionState.UNSUPPORTED -> MessageEncryption.Undecryptable(UndecryptableReason.Unsupported) + EncryptionState.AUTH_FAILED -> MessageEncryption.Undecryptable(UndecryptableReason.Authentication) + } + // endregion } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt new file mode 100644 index 0000000000..34c53b06c8 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt @@ -0,0 +1,155 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.UndecryptableReason +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.time.Instant + +class IncomingMessageOpenerTest { + + private val chatId = ChatId(ByteArray(32) { 7 }) + private val self: ID = List(16) { 1 } + private val peer: ID = List(16) { 2 } + private val selfKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val peerKeys = KeyPair(publicKey = ByteArray(32) { 21 }, privateKey = ByteArray(64) { 22 }) + + private class Keys(private val own: KeyPair, private val peerPk: ByteArray) : ChatKeySource { + var peerFails = false + var fetches = 0 + override fun ownKeyPair() = own + override suspend fun peerPublicKey(userId: ID): Result { + fetches++ + return if (peerFails) Result.failure(IOException("offline")) else Result.success(peerPk) + } + } + + private val selfSide = Keys(selfKeys, peerKeys.publicKey) + private val opener = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide)) + private val theirs = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) + private val mine = ChatContentCrypto(FakeChatCipher, Keys(selfKeys, peerKeys.publicKey)) + + private suspend fun fromPeer(text: String) = + theirs.seal(chatId, peerId = self, content = MessageContent.Text(text)).getOrThrow() + + private fun message(content: MessageContent, senderId: ID? = peer, id: Long = 1) = ChatMessage( + messageId = id, + senderId = senderId, + content = listOf(content), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 0, + isFromSelf = senderId == self, + ) + + private suspend fun open(vararg messages: ChatMessage, peerId: ID? = peer, stored: ChatMessage? = null) = + opener.open(chatId, selfId = self, peerId = peerId, messages = messages.toList(), stored = { stored }) + + @Test + fun `a message from the peer is stored as its plaintext beside the ciphertext`() = runTest { + val sealed = fromPeer("hi") + + val opened = open(message(sealed)).single() + + assertEquals(listOf(MessageContent.Text("hi")), opened.content) + assertEquals(MessageEncryption.Decrypted(sealed), opened.encryption) + } + + @Test + fun `the viewer's own message opens against the stored peer`() = runTest { + val sealed = mine.seal(chatId, peerId = peer, content = MessageContent.Text("mine")).getOrThrow() + + val opened = open(message(sealed, senderId = self)).single() + + assertEquals(listOf(MessageContent.Text("mine")), opened.content) + } + + @Test + fun `the viewer's own message waits for the peer when members aren't stored`() = runTest { + val sealed = mine.seal(chatId, peerId = peer, content = MessageContent.Text("mine")).getOrThrow() + + val opened = open(message(sealed, senderId = self), peerId = null).single() + + assertEquals(MessageEncryption.KeyPending, opened.encryption) + assertEquals(listOf(sealed), opened.content) + } + + @Test + fun `a failed key fetch leaves the message pending rather than undecryptable`() = runTest { + val sealed = fromPeer("hi") + selfSide.peerFails = true + + val opened = open(message(sealed)).single() + + assertEquals(MessageEncryption.KeyPending, opened.encryption) + assertEquals(listOf(sealed), opened.content) + } + + @Test + fun `tampered ciphertext fails authentication`() = runTest { + val sealed = fromPeer("hi") + val tampered = sealed.copy(ciphertext = sealed.ciphertext.copyOf().also { it[0] = (it[0] + 1).toByte() }) + + val opened = open(message(tampered)).single() + + assertEquals(MessageEncryption.Undecryptable(UndecryptableReason.Authentication), opened.encryption) + } + + @Test + fun `an unknown scheme is unsupported`() = runTest { + val opened = open(message(fromPeer("hi").copy(scheme = 2))).single() + + assertEquals(MessageEncryption.Undecryptable(UndecryptableReason.Unsupported), opened.encryption) + } + + @Test + fun `a stored copy with the same ciphertext is reused without opening`() = runTest { + val sealed = fromPeer("hi") + val stored = message(MessageContent.Text("hi")).copy(encryption = MessageEncryption.Decrypted(sealed)) + + val opened = open(message(sealed), stored = stored).single() + + assertEquals(stored.content, opened.content) + assertEquals(0, selfSide.fetches) + } + + @Test + fun `an edit arrives as new ciphertext and is opened again`() = runTest { + val original = fromPeer("hi") + val edited = fromPeer("hello") + val stored = message(MessageContent.Text("hi")).copy(encryption = MessageEncryption.Decrypted(original)) + + val opened = open(message(edited), stored = stored).single() + + assertEquals(listOf(MessageContent.Text("hello")), opened.content) + } + + @Test + fun `plaintext and confirmed sends pass through`() = runTest { + val plain = message(MessageContent.Text("plain")) + val confirmed = message(MessageContent.Text("sent"), senderId = self) + .copy(encryption = MessageEncryption.Decrypted(fromPeer("x"))) + + assertEquals(listOf(plain, confirmed), open(plain, confirmed)) + } + + @Test + fun `a pending message opens once the key is back`() = runTest { + val sealed = fromPeer("hi") + val pending = message(sealed).copy(encryption = MessageEncryption.KeyPending) + + val reopened = opener.reopen(chatId, self, peer, pending) + + assertEquals(listOf(MessageContent.Text("hi")), reopened.content) + assertEquals(MessageEncryption.Decrypted(sealed), reopened.encryption) + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index cc729d3024..6b854ac127 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -18,7 +18,11 @@ import com.flipcash.services.models.chat.RosterSummary import com.flipcash.services.models.chat.ViewerState import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat +import com.flipcash.app.persistence.entities.EncryptionState +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.UndecryptableReason import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test import kotlin.time.Instant @@ -428,6 +432,55 @@ class ChatEntityMapperTest { // endregion + // region encrypted messages + + private val sealed = MessageContent.Encrypted(scheme = 1, nonce = ByteArray(24) { 3 }, ciphertext = byteArrayOf(9, 8, 7)) + + private fun encryptedMessage(content: MessageContent, encryption: MessageEncryption?) = ChatMessage( + messageId = 5, + senderId = List(16) { 2 }, + content = listOf(content), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 0, + encryption = encryption, + ) + + @Test + fun `an opened message stores its plaintext with the ciphertext beside it`() { + val message = encryptedMessage(MessageContent.Text("hi"), MessageEncryption.Decrypted(sealed)) + + val entity = mapper.toEntity(CHAT_HEX, message) + + assertEquals(EncryptionState.DECRYPTED, entity.encryptionState) + assertEquals(message, mapper.toMessage(entity)) + } + + @Test + fun `each unopened state round-trips with its ciphertext as content`() { + listOf( + MessageEncryption.KeyPending, + MessageEncryption.Undecryptable(UndecryptableReason.Unsupported), + MessageEncryption.Undecryptable(UndecryptableReason.Authentication), + ).forEach { encryption -> + val message = encryptedMessage(sealed, encryption) + + val entity = mapper.toEntity(CHAT_HEX, message) + + assertEquals(1, entity.ciphertextJson?.size) + assertEquals(message, mapper.toMessage(entity)) + } + } + + @Test + fun `a plaintext message has no encryption columns`() { + val entity = mapper.toEntity(CHAT_HEX, encryptedMessage(MessageContent.Text("plain"), null)) + + assertNull(entity.ciphertextJson) + assertNull(entity.encryptionState) + } + + // endregion + private companion object { const val CHAT_HEX = "aabbccdd" } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt index 8d0557b9f7..05aac9aa96 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt @@ -10,7 +10,11 @@ import com.flipcash.services.models.chat.MessageContent * [Decrypted], the raw [MessageContent.Encrypted] otherwise. */ sealed interface MessageEncryption { - data object Decrypted : MessageEncryption + /** + * Opened on this device, or sent from it. [sealed] is the ciphertext as it went over the wire, + * kept so a later copy of the same message can be recognised without opening it again. + */ + data class Decrypted(val sealed: MessageContent.Encrypted) : MessageEncryption /** * The chat key could not be derived because a key fetch failed. Not a decrypt failure: the From cb91e4bf634fcf0cd164980d7de356e953b75151 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 14:11:33 -0400 Subject: [PATCH 11/14] feat(chat): mark where a DM transcript turns end-to-end encrypted An "Encrypted" row sits above the oldest stored message that arrived encrypted, and at the head of the transcript when every message did (nodes 10416:1404, 10416:1490). Its position comes from the stored rows, not from use_e2ee. Tapping it opens the DM encryption sheet. The paging separator pass takes one item per gap. When the marker's gap also needs a date separator or the unread divider, the marker carries it and draws it above itself. The list's scroll-to-unread lookups match a divider nested inside the marker. --- .../core/src/main/res/values/strings.xml | 1 + .../app/messenger/internal/ChatViewModel.kt | 12 ++- .../app/messenger/internal/UnreadDivider.kt | 26 +++++- .../internal/screens/MessengerScreen.kt | 4 + .../screens/components/EncryptedMarkerRow.kt | 88 +++++++++++++++++++ .../screens/components/MessageList.kt | 5 +- .../internal/screens/components/MessageRow.kt | 7 ++ .../internal/EncryptedMarkerPlacementTest.kt | 88 +++++++++++++++++++ .../flipcash/shared/chat/models/ChatAction.kt | 3 + .../shared/chat/models/ChatListItem.kt | 20 +++++ .../flipcash/shared/chat/ChatCoordinator.kt | 6 ++ .../internal/delegates/MessagingDelegate.kt | 3 + 12 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/EncryptedMarkerRow.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/EncryptedMarkerPlacementTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 632b037d4b..097ef1c9ff 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -984,6 +984,7 @@ Ask %1$s to send it again Try sending it again + Encrypted Messages are end-to-end encrypted Group chats aren\'t end-to-end encrypted Learn More 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 ffb5f35b4d..28edbb36f2 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 @@ -724,6 +724,13 @@ internal class ChatViewModel @Inject constructor( // cache: an edit or delete awaiting the server re-runs the mapping without re-fetching. .cachedIn(viewModelScope) + /** Where the Encrypted marker goes: read from the transcript, never from `use_e2ee`. */ + @OptIn(ExperimentalCoroutinesApi::class) + private val oldestEncryptedId = stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeOldestEncryptedMessageId(it) } + .distinctUntilChanged() + /** Edits and deletes the server has not answered yet, composed over the stored transcript. */ @OptIn(ExperimentalCoroutinesApi::class) private val pendingMutations = stateFlow.mapNotNull { it.chatId } @@ -1008,7 +1015,10 @@ internal class ChatViewModel @Inject constructor( .filterNot { it is UnreadBoundary.Resolving } .distinctUntilChanged(), stateFlow.map { it.separatorConfig }.distinctUntilChanged(), - ) { paging, boundary, config -> paging.withSeparators(boundary, config) } + oldestEncryptedId, + ) { paging, boundary, config, oldestEncrypted -> + paging.withSeparators(boundary, config, oldestEncrypted) + } /** * The citation shown for [this] message. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt index 2f8a138dff..ba2f896b07 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt @@ -35,14 +35,35 @@ internal fun unreadDividerBetween( return olderId <= boundary.readThrough && boundary.readThrough < newer.messageId } -/** The one item that goes in the gap between [newer] and [older] in the newest-first list, if any. */ +/** + * The one item that goes in the gap between [newer] and [older] in the newest-first list, if any. + * + * [oldestEncryptedId] is the oldest stored message that arrived end-to-end encrypted. The gap below + * its last row takes the Encrypted marker, carrying whatever separator the gap would have had. + */ internal fun separatorBetween( newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble?, boundary: UnreadBoundary, config: SeparatorConfig, + oldestEncryptedId: Long? = null, ): ChatListItem? { newer ?: return null + val separator = plainSeparatorBetween(newer, older, boundary, config) + val marksEncryption = newer.messageId == oldestEncryptedId && older?.messageId != newer.messageId + if (!marksEncryption) return separator + // At the head the list draws the oldest date as a trailing header unless the oldest item + // already carries one, and the marker is now the oldest item. + val above = separator ?: if (older == null) ChatListItem.DateSeparator(newer.timestamp) else null + return ChatListItem.EncryptedMarker(above) +} + +private fun plainSeparatorBetween( + newer: ChatListItem.ContentBubble, + older: ChatListItem.ContentBubble?, + boundary: UnreadBoundary, + config: SeparatorConfig, +): ChatListItem? { if (boundary is UnreadBoundary.At && unreadDividerBetween(newer, older, boundary)) { // The oldest stored message has no separator of its own; the list draws its date as a // trailing header. The divider sits there instead, so it carries that date. @@ -58,7 +79,8 @@ internal fun separatorBetween( internal fun PagingData.withSeparators( boundary: UnreadBoundary, config: SeparatorConfig, + oldestEncryptedId: Long? = null, ): PagingData = insertSeparators { newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble? -> - separatorBetween(newer, older, boundary, config) + separatorBetween(newer, older, boundary, config, oldestEncryptedId) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index cf15c2ab88..28a075b425 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -136,6 +136,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + ChatAction.OpenEncryptionInfo -> keyboard.hideIfVisible { + navigator.push(AppRoute.Messaging.E2eeDmInfo) + } + ChatAction.AddCash -> { // The gate is the only place in the transcript that offers it. viewModel.dispatchEvent(ChatViewModel.Event.GateFundingTapped(GroupGateFunding.ADD_CASH)) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/EncryptedMarkerRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/EncryptedMarkerRow.kt new file mode 100644 index 0000000000..64aa760ae8 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/EncryptedMarkerRow.kt @@ -0,0 +1,88 @@ +package com.flipcash.app.messenger.internal.screens.components + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Lock +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.semantics.Role +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.sp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.features.messenger.R +import com.flipcash.shared.chat.models.ChatListItem +import com.getcode.theme.CodeTheme +import kotlin.time.Clock + +/** + * "🔒 Encrypted ›" (node 10416:1404), above the first message that arrived end-to-end encrypted. + * [above] is the date separator or unread divider that shares its gap, drawn first so the reader + * sees the day, then the divider, then the marker and the message. + */ +@Composable +internal fun EncryptedMarkerRow( + above: ChatListItem?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + when (above) { + is ChatListItem.DateSeparator -> DateSeparatorRow(above.timestamp) + is ChatListItem.UnreadDivider -> UnreadDividerRow(count = above.count, date = above.date) + else -> Unit + } + Row( + modifier = Modifier + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = CodeTheme.colors.textMain, + ) + Text( + text = stringResource(R.string.label_e2eeMarker), + style = CodeTheme.typography.textSmall.copy( + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ), + color = CodeTheme.colors.textMain, + ) + Icon( + modifier = Modifier.size(14.dp), + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = CodeTheme.colors.textMain, + ) + } + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_EncryptedMarker() { + Column { + EncryptedMarkerRow(above = null, onClick = {}) + EncryptedMarkerRow(above = ChatListItem.DateSeparator(Clock.System.now()), onClick = {}) + EncryptedMarkerRow(above = ChatListItem.UnreadDivider(count = 3), onClick = {}) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 7146656712..e9d482de72 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -483,6 +483,7 @@ internal fun MessageList( is ChatListItem.ContentBubble -> oldest.timestamp is ChatListItem.DateSeparator -> null // already a separator is ChatListItem.UnreadDivider -> null // carries the oldest message's date + is ChatListItem.EncryptedMarker -> null // carries it too, as `above` null -> null } if (oldestTimestamp != null) { @@ -738,7 +739,7 @@ private suspend fun LazyPagingItems.walkUntil(budget: Int, find: ( /** The presented index of the unread divider, or `null` while it is still unloaded. */ private fun unreadDividerIndex(messages: LazyPagingItems): Int? = - (0 until messages.itemCount).firstOrNull { messages.peek(it) is ChatListItem.UnreadDivider } + (0 until messages.itemCount).firstOrNull { messages.peek(it)?.holdsUnreadDivider == true } /** * Whether the unread divider is laid out whole below the top bar. A divider under the bar's fade @@ -748,7 +749,7 @@ private fun LazyListLayoutInfo.showsUnreadDivider(messages: LazyPagingItems info.index < messages.itemCount && - messages.peek(info.index) is ChatListItem.UnreadDivider && + messages.peek(info.index)?.holdsUnreadDivider == true && info.offset >= band.first && info.offset + info.size <= band.last } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 418fbec089..182b5a842a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -290,6 +290,13 @@ internal fun MessageRow( UnreadDividerRow(count = item.count, date = item.date) } + is ChatListItem.EncryptedMarker -> Box(insertionModifier) { + EncryptedMarkerRow( + above = item.above, + onClick = { onAction(ChatAction.OpenEncryptionInfo) }, + ) + } + is ChatListItem.ContentBubble -> { val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) // Bound to a local: `sender` is a property of another module's public API, so diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/EncryptedMarkerPlacementTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/EncryptedMarkerPlacementTest.kt new file mode 100644 index 0000000000..b7474ffdbd --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/EncryptedMarkerPlacementTest.kt @@ -0,0 +1,88 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.UnreadBoundary +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.MessagePart +import com.flipcash.shared.chat.models.SeparatorConfig +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class EncryptedMarkerPlacementTest { + + private val noon = Instant.fromEpochMilliseconds(1_700_000_000_000) + + private fun row(id: Long, at: Instant = noon, part: MessagePart? = null) = ChatListItem.ContentBubble( + messageId = id, + contentIndex = 0, + content = MessageContent.Text("m$id"), + isFromSelf = false, + timestamp = at, + part = part, + ) + + private fun between( + newer: ChatListItem.ContentBubble, + older: ChatListItem.ContentBubble?, + oldestEncryptedId: Long?, + boundary: UnreadBoundary = UnreadBoundary.None, + ) = separatorBetween(newer, older, boundary, SeparatorConfig.DayOnly, oldestEncryptedId) + + /** Oldest first; the ids the marker sits above. */ + private fun markedAbove(oldestEncryptedId: Long?, vararg oldestFirst: ChatListItem.ContentBubble): List { + val newestFirst = oldestFirst.reversed() + return newestFirst.indices.mapNotNull { i -> + val newer = newestFirst[i] + newer.messageId.takeIf { + between(newer, newestFirst.getOrNull(i + 1), oldestEncryptedId) is ChatListItem.EncryptedMarker + } + } + } + + @Test + fun `the marker sits above the oldest encrypted message, below older plaintext`() = + assertEquals(listOf(3L), markedAbove(3, row(1), row(2), row(3), row(4))) + + @Test + fun `no encrypted message, no marker`() = assertEquals(emptyList(), markedAbove(null, row(1), row(2))) + + @Test + fun `a transcript encrypted from the start has the marker at the head, carrying the date`() { + assertEquals(listOf(1L), markedAbove(1, row(1), row(2))) + val marker = assertIs(between(row(1), older = null, oldestEncryptedId = 1)) + assertEquals(ChatListItem.DateSeparator(noon), marker.above) + } + + @Test + fun `a day change in the same gap is drawn above the marker`() { + val marker = assertIs( + between(row(2, at = noon + 1.days), row(1), oldestEncryptedId = 2), + ) + assertEquals(ChatListItem.DateSeparator(noon + 1.days), marker.above) + } + + @Test + fun `the same day keeps the marker alone`() = + assertNull(assertIs(between(row(2), row(1), oldestEncryptedId = 2)).above) + + @Test + fun `the unread divider in the same gap is drawn above the marker`() { + val marker = assertIs( + between(row(2), row(1), oldestEncryptedId = 2, boundary = UnreadBoundary.At(1, 1)), + ) + assertEquals(ChatListItem.UnreadDivider(1), marker.above) + assertEquals(true, marker.holdsUnreadDivider) + } + + @Test + fun `the marker never splits the rows of one message`() { + val card = row(2, part = MessagePart.Card) + val leading = row(2, part = MessagePart.Leading) + assertNull(between(newer = card, older = leading, oldestEncryptedId = 2)) + assertIs(between(newer = leading, older = row(1), oldestEncryptedId = 2)) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 3be61318e0..3e5712c414 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -22,6 +22,9 @@ sealed interface ChatAction { */ data object AddCash : ChatAction + /** Opens the DM encryption explainer, from the transcript's Encrypted marker. */ + data object OpenEncryptionInfo : ChatAction + /** * Opens the group a link card names, pushed over this chat so Back returns here. The pushed * screen gates itself; a card never joins or buys on the reader's behalf. diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index e5137edd36..7978ace09c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -32,6 +32,26 @@ sealed interface ChatListItem { override val itemContentType: Any = "unread-divider" } + /** + * "Encrypted", above the oldest message in the transcript that arrived end-to-end encrypted + * (node 10416:1404). Placed from the transcript, not from the chat's flag, so it can't claim + * encryption that isn't happening. + * + * The list takes one item per gap, so [above] is the [DateSeparator] or [UnreadDivider] the + * marker displaced from that gap, drawn above it. At the head of the transcript it is the oldest + * message's date, which the list would otherwise draw as a trailing header. + * + * At most one per transcript, so its key is fixed. + */ + data class EncryptedMarker(val above: ChatListItem? = null) : ChatListItem { + override val itemKey: Any = "encrypted-marker" + override val itemContentType: Any = "encrypted-marker" + } + + /** Whether this is the unread divider, alone or drawn above the Encrypted marker. */ + val holdsUnreadDivider: Boolean + get() = this is UnreadDivider || (this is EncryptedMarker && above is UnreadDivider) + data class ContentBubble( val messageId: Long, val contentIndex: Int, diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 298b40bd86..0bc6753078 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -361,6 +361,12 @@ interface MessagingOperations { /** Observes the other member's read pointer in [chatId] (for read receipts). */ fun observeOtherReadPointer(chatId: ChatId): Flow + /** + * The id of the oldest stored message in [chatId] that arrived end-to-end encrypted, or `null` + * when none did. The transcript's Encrypted marker sits above it. + */ + fun observeOldestEncryptedMessageId(chatId: ChatId): Flow + /** Fetches the full message history for [chatId] from the server and persists locally. */ suspend fun loadMessages(chatId: ChatId) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index ac2f69aee4..48d0d43ea8 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -235,6 +235,9 @@ class MessagingDelegate @Inject constructor( override fun requestSenderProfile(userId: ID) = senderResolver.request(userId) + override fun observeOldestEncryptedMessageId(chatId: ChatId): Flow = + messageDataSource.observeOldestEncryptedMessageId(chatId) + override fun observeOtherReadPointer(chatId: ChatId): Flow { val selfId = userManager.accountId return memberDataSource.observeMembers(chatId) From 3412b4159c2a158d7d4bad698d97061fcd86bf11 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 14:15:39 -0400 Subject: [PATCH 12/14] feat(notifications): show an encrypted DM push as its plaintext The server can't read an end-to-end encrypted DM, so the body it pushes is a stand-in. applyChatStyle now asks the chat coordinator to open the pushed message and shows its text instead. A long message arrives as message_id only; that one is read from Room if stored, else fetched with GetMessage. Any failure (plaintext message, bad ciphertext, key or message fetch failure) returns null and the notification keeps the server's body. Group chats never encrypt, so they skip the lookup. --- .../flipcash/shared/chat/ChatCoordinator.kt | 10 ++ .../internal/delegates/MessagingDelegate.kt | 35 +++++ .../chat/MessagingPushDecryptionTest.kt | 145 ++++++++++++++++++ .../app/notifications/NotificationService.kt | 19 ++- .../network/extensions/ProtobufToLocal.kt | 7 +- .../services/models/NotificationPayload.kt | 6 +- 6 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 0bc6753078..a07750b4ff 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -379,6 +379,16 @@ interface MessagingOperations { */ suspend fun applyPushedMessage(chatId: ChatId, message: ChatMessage) + /** + * The plaintext of an end-to-end encrypted message a push is for, opened on this device, for + * the notification to show in place of the server's body. The push carries [message], or only + * [messageId], in which case the message is read from storage or fetched with `GetMessage`. + * + * `null` when the message isn't encrypted, isn't text, or can't be opened or fetched: the + * notification keeps the server's body. Nothing is stored; the push's sync work does that. + */ + suspend fun openPushedMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): String? + /** * Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. * diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 48d0d43ea8..245e1f4739 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -14,6 +14,8 @@ import com.flipcash.app.analytics.analytics 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.IncomingMessageOpener +import com.flipcash.services.chat.MessageEncryption import com.flipcash.app.persistence.sources.mediator.ChatMessageRemoteMediator import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ChatMessagingController @@ -92,6 +94,8 @@ class MessagingDelegate @Inject constructor( private val senderResolver: SenderResolver, private val linkPrefetch: MessageLinkPrefetch = MessageLinkPrefetch.None, private val outgoing: OutgoingEncryption = OutgoingEncryption.None, + /** Opens encrypted pushes; without it, every push keeps the server's body. */ + private val incoming: IncomingMessageOpener? = null, ) : MessagingOperations { /** @@ -286,6 +290,37 @@ class MessagingDelegate @Inject constructor( } } + override suspend fun openPushedMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): String? { + val opener = incoming ?: return null + val selfId = userManager.accountId ?: return null + val candidate = message + ?: messageId?.let { id -> + messageDataSource.getMessage(chatId, id) + ?: messagingController.getMessage(chatId, id).getOrNull() + } + ?: return null + + val opened = when (candidate.encryption) { + is MessageEncryption.Decrypted -> candidate + null -> { + if (candidate.content.singleOrNull() !is MessageContent.Encrypted) return null + // Only the viewer's own message needs the other member; anyone else's is theirs. + val peerId = if (candidate.senderId == selfId) getOtherMember(chatId)?.userId else null + opener.open(chatId, selfId, peerId, listOf(candidate), stored = { null }).single() + } + else -> return null + } + if (opened.encryption !is MessageEncryption.Decrypted) return null + return opened.content.singleOrNull()?.pushText() + } + + /** Text and replies with text are what DMs encrypt; anything else keeps the server's body. */ + private fun MessageContent.pushText(): String? = when (this) { + is MessageContent.Text -> text + is MessageContent.Reply -> (content.singleOrNull() as? MessageContent.Text)?.text + else -> null + } + override suspend fun sendMessage( chatId: ChatId, content: String, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt new file mode 100644 index 0000000000..e5e7c45b8f --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt @@ -0,0 +1,145 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.IncomingMessageOpener +import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** What a DM push shows: the opened plaintext, or `null` so the server's body stays. */ +class MessagingPushDecryptionTest { + + private val chatId = ChatId(ByteArray(32) { 7 }) + private val self: ID = List(16) { 1 } + private val peer: ID = List(16) { 2 } + private val selfKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val peerKeys = KeyPair(publicKey = ByteArray(32) { 21 }, privateKey = ByteArray(64) { 22 }) + + private class Keys(private val own: KeyPair, private val peerPk: ByteArray) : ChatKeySource { + var peerFails = false + override fun ownKeyPair() = own + override suspend fun peerPublicKey(userId: ID): Result = + if (peerFails) Result.failure(IOException("offline")) else Result.success(peerPk) + } + + private val selfSide = Keys(selfKeys, peerKeys.publicKey) + private val theirs = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) + private val messageDataSource = mockk(relaxed = true) + private val messagingController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) { + every { accountId } returns self + } + + private val delegate = MessagingDelegate( + chatController = mockk(relaxed = true), + messagingController = messagingController, + metadataDataSource = mockk(relaxed = true), + messageDataSource = messageDataSource, + memberDataSource = mockk(relaxed = true), + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = mockk(relaxed = true), + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + incoming = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide)), + ) + + private suspend fun sealedFromPeer(content: MessageContent) = + theirs.seal(chatId, peerId = self, content = content).getOrThrow() + + private fun message(content: MessageContent, id: Long = 5) = ChatMessage( + messageId = id, + senderId = peer, + content = listOf(content), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 0, + ) + + @Test + fun `an inlined encrypted message shows its plaintext`() = runTest { + val pushed = message(sealedFromPeer(MessageContent.Text("see you at 8"))) + + assertEquals("see you at 8", delegate.openPushedMessage(chatId, pushed, messageId = null)) + } + + @Test + fun `a reply shows the text it carries`() = runTest { + val reply = MessageContent.Reply(repliedMessageId = 2, content = listOf(MessageContent.Text("yes"))) + val pushed = message(sealedFromPeer(reply)) + + assertEquals("yes", delegate.openPushedMessage(chatId, pushed, messageId = null)) + } + + @Test + fun `an id-only push fetches the message when it isn't stored`() = runTest { + val fetched = message(sealedFromPeer(MessageContent.Text("long one")), id = 9) + coEvery { messageDataSource.getMessage(chatId, 9) } returns null + coEvery { messagingController.getMessage(chatId, 9, any()) } returns Result.success(fetched) + + assertEquals("long one", delegate.openPushedMessage(chatId, message = null, messageId = 9)) + } + + @Test + fun `an id-only push reads an already-opened stored copy without fetching`() = runTest { + val sealed = sealedFromPeer(MessageContent.Text("stored")) + val stored = message(MessageContent.Text("stored"), id = 9) + .copy(encryption = MessageEncryption.Decrypted(sealed)) + coEvery { messageDataSource.getMessage(chatId, 9) } returns stored + + assertEquals("stored", delegate.openPushedMessage(chatId, message = null, messageId = 9)) + coVerify(exactly = 0) { messagingController.getMessage(any(), any(), any()) } + } + + @Test + fun `a failed fetch keeps the server's body`() = runTest { + coEvery { messageDataSource.getMessage(chatId, 9) } returns null + coEvery { messagingController.getMessage(chatId, 9, any()) } returns Result.failure(IOException("offline")) + + assertNull(delegate.openPushedMessage(chatId, message = null, messageId = 9)) + } + + @Test + fun `a plaintext message keeps the server's body`() = runTest { + assertNull(delegate.openPushedMessage(chatId, message(MessageContent.Text("hi")), messageId = null)) + } + + @Test + fun `a message that fails to open keeps the server's body`() = runTest { + val sealed = sealedFromPeer(MessageContent.Text("hi")) + val tampered = sealed.copy(ciphertext = sealed.ciphertext.copyOf().also { it[0] = (it[0] + 1).toByte() }) + + assertNull(delegate.openPushedMessage(chatId, message(tampered), messageId = null)) + } + + @Test + fun `a key fetch failure keeps the server's body`() = runTest { + val pushed = message(sealedFromPeer(MessageContent.Text("hi"))) + selfSide.peerFails = true + + assertNull(delegate.openPushedMessage(chatId, pushed, messageId = null)) + } + + @Test + fun `nothing to open without a message or its id`() = runTest { + assertNull(delegate.openPushedMessage(chatId, message = null, messageId = null)) + } +} diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index a090fc7ba3..6ec1153d2a 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -53,6 +53,7 @@ import com.getcode.utils.trace import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -404,7 +405,10 @@ class NotificationService : FirebaseMessagingService(), ?.let { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) } ?: NotificationCompat.MessagingStyle(selfPerson) - style.addMessage(planMessageBody(body, senderNames), System.currentTimeMillis(), senderPerson) + // The server can't read an end-to-end encrypted DM, so its body is a stand-in. The opened + // text is the message itself, with no sender prefix to strip. + val opened = if (!styling.isGroupConversation) openPushedMessage(chatId, metadata) else null + style.addMessage(opened ?: planMessageBody(body, senderNames), System.currentTimeMillis(), senderPerson) // After the extract above, not before: a re-post rebuilds the style from the notification // already on screen, which carries the old flag and title back with it. @@ -421,6 +425,19 @@ class NotificationService : FirebaseMessagingService(), return notificationId } + /** The pushed DM message's plaintext, or `null` to keep the server's body on any failure. */ + private suspend fun openPushedMessage(chatId: ChatId, metadata: PushChatMetadata?): String? { + if (metadata == null || (metadata.message == null && metadata.messageId == null)) return null + return try { + chatCoordinator.openPushedMessage(chatId, metadata.message, metadata.messageId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + trace(tag = "NotificationService", message = "Couldn't open a pushed message", error = e) + null + } + } + /** Who a chat push is attributed to: their id (for blob access) and their profile. */ private data class Sender(val userId: ID, val profile: UserProfile) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 7a5a3cc695..ac0f46d624 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -117,10 +117,9 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload { sendingUserId = if (chatMetadata.hasSendingUserId()) chatMetadata.sendingUserId.toId() else null, chatType = chatMetadata.type.toChatType(), message = if (chatMetadata.hasMessage()) chatMetadata.message.toChatMessage() else null, - // TODO(push/v1 ChatMetadata.message_ref): a long message now arrives as - // `message_id` only (fetch via Messaging.GetMessage(chatId, messageId)). Carried - // here for a future fetch; today the message-less case still falls back to - // PushHandlingPlanner's existing LoadMessages sync, so nothing is dropped. + // push/v1 ChatMetadata.message_ref: a long message arrives as `message_id` only. The + // notification fetches it with GetMessage to open an encrypted DM; storing it is left + // to PushHandlingPlanner's LoadMessages sync. messageId = if (chatMetadata.hasMessageId()) chatMetadata.messageId.value else null, muted = chatMetadata.muted, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt index bc32f9bac1..0f6c389097 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt @@ -50,9 +50,9 @@ data class PushChatMetadata( // notification for it. val muted: Boolean = false, // Set when the server sent only the message's id (push/v1 ChatMetadata.message_ref, the - // `message_id` arm -- used for long messages instead of inlining `message`). Not yet - // fetched: see the TODO at ProtobufToLocal.asPayload(). [message] stays null in that case, - // and callers already fall back to their existing no-message sync path. + // `message_id` arm -- used for long messages instead of inlining `message`). [message] stays + // null in that case: the sync path loads the chat, and the notification fetches the one + // message to open it when the DM is end-to-end encrypted. val messageId: Long? = null, ) From 4f57ffd3181e0e6322484f18e8be02c309b24e66 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 14:26:59 -0400 Subject: [PATCH 13/14] fix(chat): refresh use_e2ee when a stored chat syncs ChatMetadataDao.upsert wrote use_e2ee only on first insert; updateServerOwnedFields left it out. A DM stored before the server set the flag kept use_e2ee = 0 on every later sync, so sends in it stayed plaintext and the profile footer never showed. The column came in with #1573, so code/cash has the same gap. --- .../app/persistence/dao/ChatMetadataDao.kt | 5 ++++- .../persistence/dao/ChatMetadataDaoTest.kt | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index 7b096cb346..8518a1e520 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -86,7 +86,8 @@ interface ChatMetadataDao { "title = :title, " + "picture_json = :pictureJson, " + "rules_json = :rulesJson, " + - "is_member = :isMember " + + "is_member = :isMember, " + + "use_e2ee = :useE2ee " + "WHERE chat_id_hex = :chatIdHex" ) suspend fun updateServerOwnedFields( @@ -99,6 +100,7 @@ interface ChatMetadataDao { pictureJson: MediaItem?, rulesJson: ChatRulesSerialized?, isMember: Boolean, + useE2ee: Boolean, ) /** @@ -198,6 +200,7 @@ interface ChatMetadataDao { pictureJson = entity.pictureJson, rulesJson = entity.rulesJson, isMember = entity.isMember, + useE2ee = entity.useE2ee, ) updateRosterIfNewer( chatIdHex = entity.chatIdHex, diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index a47da549e3..efd55a45d3 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -62,6 +62,7 @@ class ChatMetadataDaoTest { muteForever: Boolean = false, viewerStateVersion: Long = 0, canEdit: Boolean = false, + useE2ee: Boolean = false, ) = ChatMetadataEntity( chatIdHex = chatIdHex, chatType = chatType, @@ -80,6 +81,7 @@ class ChatMetadataDaoTest { muteForever = muteForever, viewerStateVersion = viewerStateVersion, canEdit = canEdit, + useE2ee = useE2ee, ) @Test @@ -172,6 +174,24 @@ class ChatMetadataDaoTest { assertEquals(false, stored?.isMember) } + @Test + fun `upsert picks up use_e2ee turning on for a stored chat`() = runTest { + dao.upsert(entity(useE2ee = false)) + + dao.upsert(entity(useE2ee = true)) + + assertEquals(true, dao.getById(CHAT_HEX)?.useE2ee) + } + + @Test + fun `upsert picks up use_e2ee turning off for a stored chat`() = runTest { + dao.upsert(entity(useE2ee = true)) + + dao.upsert(entity(useE2ee = false)) + + assertEquals(false, dao.getById(CHAT_HEX)?.useE2ee) + } + /** * `MetadataUpdate.TitleChanged` carries no version, unlike the roster and viewer-state * overlays above, so there is nothing to gate the write on. From 4123b15651741b2a53b9ffb9c86b2abf43fde7a1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 29 Sep 2026 14:34:42 -0400 Subject: [PATCH 14/14] fix(chat): draw the Encrypted marker in the secondary text color It matches the date separator above it instead of the white the plan called for. --- .../internal/screens/components/EncryptedMarkerRow.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/EncryptedMarkerRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/EncryptedMarkerRow.kt index 64aa760ae8..8ee2776667 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/EncryptedMarkerRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/EncryptedMarkerRow.kt @@ -56,7 +56,7 @@ internal fun EncryptedMarkerRow( modifier = Modifier.size(12.dp), imageVector = Icons.Filled.Lock, contentDescription = null, - tint = CodeTheme.colors.textMain, + tint = CodeTheme.colors.textSecondary, ) Text( text = stringResource(R.string.label_e2eeMarker), @@ -64,13 +64,13 @@ internal fun EncryptedMarkerRow( fontSize = 12.sp, fontWeight = FontWeight.SemiBold, ), - color = CodeTheme.colors.textMain, + color = CodeTheme.colors.textSecondary, ) Icon( modifier = Modifier.size(14.dp), imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, - tint = CodeTheme.colors.textMain, + tint = CodeTheme.colors.textSecondary, ) } }