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 6d89499dec..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
@@ -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
@@ -722,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 }
@@ -952,6 +961,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
@@ -998,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..8ee2776667
--- /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.textSecondary,
+ )
+ Text(
+ text = stringResource(R.string.label_e2eeMarker),
+ style = CodeTheme.typography.textSmall.copy(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ color = CodeTheme.colors.textSecondary,
+ )
+ Icon(
+ modifier = Modifier.size(14.dp),
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ tint = CodeTheme.colors.textSecondary,
+ )
+ }
+ }
+}
+
+@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/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/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 9ae6456c18..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
@@ -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
@@ -31,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,
@@ -125,6 +146,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/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/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
index 298b40bd86..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
@@ -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)
@@ -373,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/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..ad81279141
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt
@@ -0,0 +1,109 @@
+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 =
+ when (val sealed = wire.singleOrNull()) {
+ is MessageContent.Encrypted if isSealed -> serverMessage.copy(
+ content = plaintext,
+ encryption = MessageEncryption.Decrypted(sealed),
+ )
+ 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/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 5dc55342c8..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
@@ -21,6 +23,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 +42,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 +75,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 +93,9 @@ class MessagingDelegate @Inject constructor(
private val analytics: FlipcashAnalytics,
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 {
/**
@@ -229,6 +239,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)
@@ -277,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,
@@ -301,23 +345,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 +412,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 }
}
@@ -558,6 +621,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/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/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..f8435de1d9
--- /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(wires.single().single() as MessageContent.Encrypted), 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(MessageContent.Encrypted(1, ByteArray(24), ByteArray(8))))
+ 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(wires.single().single() as MessageContent.Encrypted), 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/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/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/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/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/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.
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/build.gradle.kts b/services/flipcash/build.gradle.kts
index e8fc17062d..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,6 +32,9 @@ dependencies {
api(project(":libs:network:jwt"))
api(project(":services:opencode"))
implementation(project(":ui:resources"))
+ // `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/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/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/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..05aac9aa96
--- /dev/null
+++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt
@@ -0,0 +1,44 @@
+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 {
+ /**
+ * 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
+ * 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..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,
)
@@ -194,10 +193,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/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,
)
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..967774e60c
--- /dev/null
+++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt
@@ -0,0 +1,159 @@
+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.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)
+ }
+}
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)))
- }
}
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()
+}