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