Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -127,6 +128,8 @@ fun appEntryProvider(
ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry<AppRoute.Messaging.MuteChat> { key -> MuteChatScreen(key.chatId, key.chatType) }
annotatedEntry<AppRoute.Messaging.E2eeDmInfo> { E2eeLearnMoreScreen(forGroup = false) }
annotatedEntry<AppRoute.Messaging.E2eeGroupInfo> { E2eeLearnMoreScreen(forGroup = true) }

// Tokens
annotatedEntry<AppRoute.Token.Info>(testTag = "token_info_screen") { key ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -979,7 +979,30 @@
<string name="label_edited">Edited</string>
<string name="label_messageDeletedByYou">You deleted this message</string>
<string name="label_messageDeleted">This message was deleted</string>
<string name="label_messageUnsupported">This message isn\'t supported on this version</string>
<string name="label_messageUndecryptable">This message can\'t be displayed</string>
<string name="hint_messageUndecryptable_update">Update Flipcash to see it</string>
<string name="hint_messageUndecryptable_askToResend">Ask %1$s to send it again</string>
<string name="hint_messageUndecryptable_tryAgain">Try sending it again</string>

<string name="label_e2eeFooter_dm">Messages are end-to-end encrypted</string>
<string name="label_e2eeFooter_group">Group chats aren\'t end-to-end encrypted</string>
<string name="action_learnMore">Learn More</string>

<string name="title_e2eeSheet_dm">Your messages are private</string>
<string name="subtitle_e2eeSheet_dm">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.</string>
<string name="title_e2eeSheet_group">Group chats aren\'t encrypted</string>
<string name="subtitle_e2eeSheet_group">End-to-end encryption covers chats between two people. Messages in group chats are stored on Flipcash servers in a form Flipcash can read.</string>
<string name="header_e2eeSheet_encrypted">ENCRYPTED</string>
<string name="header_e2eeSheet_notEncrypted">NOT ENCRYPTED</string>
<string name="item_e2eeSheet_textAndReplies">Text messages and replies</string>
<string name="item_e2eeSheet_photos">Photos you send</string>
<string name="item_e2eeSheet_tipsAndPayments">Tips and payments, which are recorded on Solana</string>
<string name="item_e2eeSheet_reactions">Reactions, and who sent them</string>
<string name="item_e2eeSheet_editedOrDeleted">That a message was edited or deleted</string>
<string name="item_e2eeSheet_groupChats">Group chats</string>
<string name="item_e2eeSheet_beforeEncryption">Messages sent before encryption was turned on</string>
<string name="item_e2eeSheet_messagesInDms">Messages in DMs</string>
<string name="item_e2eeSheet_groupChatsIncludingThis">Group chats, including this one</string>

<!-- Cash link card: the state of the link a message carries, read without claiming it -->
<string name="label_linkCard_cash">Cash</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ActiveTypist> = emptySet(),
/** What the typing indicator draws ahead of its dots. See [typingAvatars]. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int>,
@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 = {})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand All @@ -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
}

Expand All @@ -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,
)

/**
Expand All @@ -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
Expand Down
Loading
Loading