From f266debd56e99545b2fa6675a2e86426294288b7 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 14 Dec 2022 17:44:26 +0300 Subject: [PATCH] android: Verify connection security code (#1567) * android: Verify connection security code * Dividers * Changes * Padding * Share connection code * Share connection code * Unused * icon sizes Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 14 +- .../java/chat/simplex/app/model/SimpleXAPI.kt | 49 +++++++ .../simplex/app/views/chat/ChatInfoView.kt | 70 +++++++-- .../chat/simplex/app/views/chat/ChatView.kt | 42 ++++-- .../simplex/app/views/chat/ScanCodeView.kt | 53 +++++++ .../simplex/app/views/chat/VerifyCodeView.kt | 137 ++++++++++++++++++ .../app/views/chat/group/GroupChatInfoView.kt | 53 +++++-- .../views/chat/group/GroupMemberInfoView.kt | 84 ++++++++--- .../app/views/chatlist/ChatPreviewView.kt | 15 +- .../app/src/main/res/values/strings.xml | 15 ++ 10 files changed, 476 insertions(+), 56 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index a7896da75d..0c222b9a98 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -574,6 +574,7 @@ data class Contact( override val fullName get() = profile.fullName override val image get() = profile.image override val localAlias get() = profile.localAlias + val verified get() = activeConn.connectionCode != null val directOrUsed: Boolean get() = (activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed @@ -612,13 +613,23 @@ class ContactSubStatus( ) @Serializable -class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val viaGroupLink: Boolean, val customUserProfileId: Long? = null) { +data class Connection( + val connId: Long, + val connStatus: ConnStatus, + val connLevel: Int, + val viaGroupLink: Boolean, + val customUserProfileId: Long? = null, + val connectionCode: SecurityCode? = null +) { val id: ChatId get() = ":$connId" companion object { val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null) } } +@Serializable +data class SecurityCode(val securityCode: String, val verifiedAt: Instant) + @Serializable data class Profile( override val displayName: String, @@ -757,6 +768,7 @@ data class GroupMember ( val displayName: String get() = memberProfile.localAlias.ifEmpty { memberProfile.displayName } val fullName: String get() = memberProfile.fullName val image: String? get() = memberProfile.image + val verified get() = activeConn?.connectionCode != null val chatViewName: String get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index ce5448c5c3..ba9d002845 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -553,6 +553,34 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + suspend fun apiGetContactCode(contactId: Long): Pair { + val r = sendCmd(CC.APIGetContactCode(contactId)) + if (r is CR.ContactCode) return r.contact to r.connectionCode + throw Exception("failed to get contact code: ${r.responseType} ${r.details}") + } + + suspend fun apiGetGroupMemberCode(groupId: Long, groupMemberId: Long): Pair { + val r = sendCmd(CC.APIGetGroupMemberCode(groupId, groupMemberId)) + if (r is CR.GroupMemberCode) return r.member to r.connectionCode + throw Exception("failed to get group member code: ${r.responseType} ${r.details}") + } + + suspend fun apiVerifyContact(contactId: Long, connectionCode: String?): Pair? { + return when (val r = sendCmd(CC.APIVerifyContact(contactId, connectionCode))) { + is CR.ConnectionVerified -> r.verified to r.expectedCode + else -> null + } + } + + suspend fun apiVerifyGroupMember(groupId: Long, groupMemberId: Long, connectionCode: String?): Pair? { + return when (val r = sendCmd(CC.APIVerifyGroupMember(groupId, groupMemberId, connectionCode))) { + is CR.ConnectionVerified -> r.verified to r.expectedCode + else -> null + } + } + + + suspend fun apiAddContact(): String? { val r = sendCmd(CC.AddContact()) return when (r) { @@ -1536,6 +1564,10 @@ sealed class CC { class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC() class APISwitchContact(val contactId: Long): CC() class APISwitchGroupMember(val groupId: Long, val groupMemberId: Long): CC() + class APIGetContactCode(val contactId: Long): CC() + class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC() + class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC() + class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() class AddContact: CC() class Connect(val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() @@ -1603,6 +1635,10 @@ sealed class CC { is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" is APISwitchContact -> "/_switch @$contactId" is APISwitchGroupMember -> "/_switch #$groupId $groupMemberId" + is APIGetContactCode -> "/_get code @$contactId" + is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId" + is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else "" + is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" is AddContact -> "/connect" is Connect -> "/connect $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" @@ -1671,6 +1707,10 @@ sealed class CC { is APIGroupMemberInfo -> "apiGroupMemberInfo" is APISwitchContact -> "apiSwitchContact" is APISwitchGroupMember -> "apiSwitchGroupMember" + is APIGetContactCode -> "apiGetContactCode" + is APIGetGroupMemberCode -> "apiGetGroupMemberCode" + is APIVerifyContact -> "apiVerifyContact" + is APIVerifyGroupMember -> "apiVerifyGroupMember" is AddContact -> "addContact" is Connect -> "connect" is ApiDeleteChat -> "apiDeleteChat" @@ -2423,6 +2463,9 @@ sealed class CR { @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() @Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR() @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR() + @Serializable @SerialName("contactCode") class ContactCode(val contact: Contact, val connectionCode: String): CR() + @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() + @Serializable @SerialName("connectionVerified") class ConnectionVerified(val verified: Boolean, val expectedCode: String): CR() @Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation: CR() @Serializable @SerialName("sentInvitation") class SentInvitation: CR() @@ -2521,6 +2564,9 @@ sealed class CR { is NetworkConfig -> "networkConfig" is ContactInfo -> "contactInfo" is GroupMemberInfo -> "groupMemberInfo" + is ContactCode -> "contactCode" + is GroupMemberCode -> "groupMemberCode" + is ConnectionVerified -> "connectionVerified" is Invitation -> "invitation" is SentConfirmation -> "sentConfirmation" is SentInvitation -> "sentInvitation" @@ -2617,6 +2663,9 @@ sealed class CR { is NetworkConfig -> json.encodeToString(networkConfig) is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}" is GroupMemberInfo -> "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}" + is ContactCode -> "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode" + is GroupMemberCode -> "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode" + is ConnectionVerified -> "verified: $verified\nconnectionCode: $expectedCode" is Invitation -> connReqInvitation is SentConfirmation -> noDetails() is SentInvitation -> noDetails() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index 060a5f5239..bfb3ef089f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -38,6 +38,7 @@ import chat.simplex.app.views.helpers.* import chat.simplex.app.views.usersettings.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* +import kotlinx.datetime.Clock @Composable fun ChatInfoView( @@ -46,6 +47,7 @@ fun ChatInfoView( connStats: ConnectionStats?, customUserProfile: Profile?, localAlias: String, + connectionCode: String?, close: () -> Unit, ) { BackHandler(onBack = close) @@ -58,6 +60,7 @@ fun ChatInfoView( connStats, customUserProfile, localAlias, + connectionCode, developerTools, onLocalAliasChanged = { setContactAlias(chat.chatInfo.apiId, it, chatModel) @@ -74,6 +77,31 @@ fun ChatInfoView( clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, switchContactAddress = { showSwitchContactAddressAlert(chatModel, contact.contactId) + }, + verifyClicked = { + ModalManager.shared.showModalCloseable { close -> + remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct -> + VerifyCodeView( + ct.displayName, + connectionCode, + ct.verified, + verify = { code -> + chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r -> + val (verified, existingCode) = r + chatModel.updateContact( + ct.copy( + activeConn = ct.activeConn.copy( + connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null + ) + ) + ) + r + } + }, + close, + ) + } + } } ) } @@ -123,12 +151,14 @@ fun ChatInfoLayout( connStats: ConnectionStats?, customUserProfile: Profile?, localAlias: String, + connectionCode: String?, developerTools: Boolean, onLocalAliasChanged: (String) -> Unit, openPreferences: () -> Unit, deleteContact: () -> Unit, clearChat: () -> Unit, switchContactAddress: () -> Unit, + verifyClicked: () -> Unit, ) { Column( Modifier @@ -154,6 +184,10 @@ fun ChatInfoLayout( SectionSpacer() SectionView { + if (connectionCode != null) { + VerifyCodeButton(contact.verified, verifyClicked) + SectionDivider() + } ContactPreferencesButton(openPreferences) } @@ -208,13 +242,17 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { horizontalAlignment = Alignment.CenterHorizontally ) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) - Text( - contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), - color = MaterialTheme.colors.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(bottom = 8.dp) - ) + Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { + if (contact.verified) { + Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight) + } + Text( + contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + color = MaterialTheme.colors.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) { Text( cInfo.fullName, style = MaterialTheme.typography.h2, @@ -276,7 +314,7 @@ fun LocalAliasEditor( } @Composable -fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) { +private fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) { Row( Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween, @@ -308,7 +346,7 @@ fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) { } @Composable -fun ServerImage(networkStatus: Chat.NetworkStatus) { +private fun ServerImage(networkStatus: Chat.NetworkStatus) { Box(Modifier.size(18.dp)) { when (networkStatus) { is Chat.NetworkStatus.Connected -> @@ -339,6 +377,16 @@ fun SwitchAddressButton(onClick: () -> Unit) { } } +@Composable +fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) { + SettingsActionItem( + if (contactVerified) Icons.Outlined.VerifiedUser else Icons.Outlined.Shield, + stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code), + click = onClick, + iconColor = HighOrLowlight, + ) +} + @Composable private fun ContactPreferencesButton(onClick: () -> Unit) { SettingsActionItem( @@ -360,7 +408,7 @@ fun ClearChatButton(onClick: () -> Unit) { } @Composable -fun DeleteContactButton(onClick: () -> Unit) { +private fun DeleteContactButton(onClick: () -> Unit) { SettingsActionItem( Icons.Outlined.Delete, stringResource(R.string.button_delete_contact), @@ -403,6 +451,7 @@ fun PreviewChatInfoLayout() { ), Contact.sampleData, localAlias = "", + connectionCode = "123", developerTools = false, connStats = null, onLocalAliasChanged = {}, @@ -411,6 +460,7 @@ fun PreviewChatInfoLayout() { deleteContact = {}, clearChat = {}, switchContactAddress = {}, + verifyClicked = {}, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 0b3a55b140..43a4d638c9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -3,6 +3,7 @@ package chat.simplex.app.views.chat import android.content.res.Configuration import android.graphics.Bitmap import android.net.Uri +import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.gestures.* @@ -12,8 +13,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.mapSaver @@ -131,10 +131,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { withApi { if (chat.chatInfo is ChatInfo.Direct) { val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) + val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId) ModalManager.shared.showModalCloseable(true) { close -> - val contact = remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } } - contact.value?.let { ct -> - ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) + remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct -> + ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) } } } else if (chat.chatInfo is ChatInfo.Group) { @@ -149,8 +149,20 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { hideKeyboard(view) withApi { val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val (_, code) = if (member.memberActive) { + try { + chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) + } catch (e: Exception) { + Log.e(TAG, e.stackTraceToString()) + member to null + } + } else { + member to null + } ModalManager.shared.showModalCloseable(true) { close -> - GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close) + remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close) + } } } }, @@ -424,10 +436,15 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo Modifier.padding(start = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - cInfo.displayName, fontWeight = FontWeight.SemiBold, - maxLines = 1, overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) { + ContactVerifiedShield() + } + Text( + cInfo.displayName, fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + } if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) { Text( cInfo.fullName, @@ -438,6 +455,11 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo } } +@Composable +private fun ContactVerifiedShield() { + Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight) +} + data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState) val CIListStateSaver = run { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt new file mode 100644 index 0000000000..e9e159137a --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt @@ -0,0 +1,53 @@ +package chat.simplex.app.views.chat + +import android.Manifest +import androidx.compose.foundation.layout.* +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import chat.simplex.app.R +import chat.simplex.app.ui.theme.DEFAULT_PADDING +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.QRCodeScanner +import com.google.accompanist.permissions.rememberPermissionState + +@Composable +fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } + ScanCodeLayout(verifyCode, close) +} + +@Composable +private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = DEFAULT_PADDING) + ) { + AppBarTitle(stringResource(R.string.scan_code), false) + Box( + Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1F) + .padding(bottom = DEFAULT_PADDING) + ) { + QRCodeScanner { text -> + verifyCode(text) { + if (it) { + close() + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.incorrect_code) + ) + } + } + } + } + Text(stringResource(R.string.scan_code_from_contacts_app)) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt new file mode 100644 index 0000000000..fd88ccdc34 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt @@ -0,0 +1,137 @@ +package chat.simplex.app.views.chat + +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.QRCode + +@Composable +fun VerifyCodeView( + displayName: String, + connectionCode: String?, + connectionVerified: Boolean, + verify: suspend (String?) -> Pair?, + close: () -> Unit, +) { + if (connectionCode != null) { + VerifyCodeLayout( + displayName, + connectionCode, + connectionVerified, + verifyCode = { newCode, cb -> + withBGApi { + val res = verify(newCode) + if (res != null) { + val (verified) = res + cb(verified) + if (verified) close() + } + } + } + ) + } +} + +@Composable +private fun VerifyCodeLayout( + displayName: String, + connectionCode: String, + connectionVerified: Boolean, + verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = DEFAULT_PADDING) + ) { + AppBarTitle(stringResource(R.string.security_code), false) + val splitCode = splitToParts(connectionCode, 24) + Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) { + if (connectionVerified) { + Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 4.dp).size(22.dp), tint = HighOrLowlight) + Text(String.format(stringResource(R.string.is_verified), displayName)) + } else { + Text(String.format(stringResource(R.string.is_not_verified), displayName)) + } + } + + SectionView { + QRCode(connectionCode, Modifier.aspectRatio(1f)) + } + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.weight(2f)) + SelectionContainer(Modifier.padding(vertical = DEFAULT_PADDING_HALF, horizontal = DEFAULT_PADDING_HALF)) { + Text( + splitCode, + fontFamily = FontFamily.Monospace, + fontSize = 18.sp, + maxLines = 20 + ) + } + val context = LocalContext.current + Box(Modifier.weight(1f)) { + IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) { + Icon(Icons.Filled.Share, null, tint = MaterialTheme.colors.primary) + } + } + Spacer(Modifier.weight(1f)) + } + + Text( + generalGetString(R.string.to_verify_compare), + Modifier.padding(bottom = DEFAULT_PADDING) + ) + + Row( + Modifier.padding(bottom = DEFAULT_PADDING).align(Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (connectionVerified) { + SimpleButton(generalGetString(R.string.clear_verification), Icons.Outlined.Shield) { + verifyCode(null) {} + } + } else { + SimpleButton(generalGetString(R.string.scan_code), Icons.Outlined.QrCode) { + ModalManager.shared.showModal { + ScanCodeView(verifyCode) { } + } + } + SimpleButton(generalGetString(R.string.mark_code_verified), Icons.Outlined.VerifiedUser) { + verifyCode(connectionCode) { verified -> + if (!verified) { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.incorrect_code) + ) + } + } + } + } + } + } +} + +private fun splitToParts(s: String, length: Int): String { + if (length >= s.length) return s + return (0..(s.length - 1) / length) + .map { s.drop(it * length).take(length) } + .joinToString(separator = "\n") +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt index 1d9a918f02..6bf43a8d03 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt @@ -6,6 +6,7 @@ import SectionItemView import SectionSpacer import SectionTextFooter import SectionView +import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -23,6 +24,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R +import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.* @@ -56,8 +58,23 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) { showMemberInfo = { member -> withApi { val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val (_, code) = if (member.memberActive) { + try { + chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) + } catch (e: Exception) { + Log.e(TAG, e.stackTraceToString()) + member to null + } + } else { + member to null + } ModalManager.shared.showModalCloseable(true) { closeCurrent -> - GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() } + remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) { + closeCurrent() + close() + } + } } } }, @@ -208,7 +225,7 @@ fun GroupChatInfoLayout( } @Composable -fun GroupChatInfoHeader(cInfo: ChatInfo) { +private fun GroupChatInfoHeader(cInfo: ChatInfo) { Column( Modifier.padding(horizontal = 8.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -241,7 +258,7 @@ private fun GroupPreferencesButton(onClick: () -> Unit) { } @Composable -fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) { +private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) { Row( Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically @@ -257,7 +274,7 @@ fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) { } @Composable -fun MembersList(members: List, showMemberInfo: (GroupMember) -> Unit) { +private fun MembersList(members: List, showMemberInfo: (GroupMember) -> Unit) { Column { members.forEachIndexed { index, member -> SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) { @@ -271,7 +288,7 @@ fun MembersList(members: List, showMemberInfo: (GroupMember) -> Uni } @Composable -fun MemberRow(member: GroupMember, user: Boolean = false) { +private fun MemberRow(member: GroupMember, user: Boolean = false) { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -283,10 +300,15 @@ fun MemberRow(member: GroupMember, user: Boolean = false) { ) { ProfileImage(size = 46.dp, member.image) Column { - Text( - member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (member.memberIncognito) Indigo else Color.Unspecified - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + MemberVerifiedShield() + } + Text( + member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (member.memberIncognito) Indigo else Color.Unspecified + ) + } val s = member.memberStatus.shortText val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s Text( @@ -306,7 +328,12 @@ fun MemberRow(member: GroupMember, user: Boolean = false) { } @Composable -fun GroupLinkButton() { +private fun MemberVerifiedShield() { + Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 3.dp).size(16.dp), tint = HighOrLowlight) +} + +@Composable +private fun GroupLinkButton() { Row( Modifier .fillMaxSize(), @@ -323,7 +350,7 @@ fun GroupLinkButton() { } @Composable -fun EditGroupProfileButton() { +private fun EditGroupProfileButton() { Row( Modifier .fillMaxSize(), @@ -340,7 +367,7 @@ fun EditGroupProfileButton() { } @Composable -fun LeaveGroupButton() { +private fun LeaveGroupButton() { Row( Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically @@ -356,7 +383,7 @@ fun LeaveGroupButton() { } @Composable -fun DeleteGroupButton() { +private fun DeleteGroupButton() { Row( Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt index bf7e1bb280..c32b52914e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt @@ -5,6 +5,7 @@ import SectionDivider import SectionItemView import SectionSpacer import SectionView +import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -12,6 +13,7 @@ import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -21,18 +23,20 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R +import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chat.SimplexServers -import chat.simplex.app.views.chat.SwitchAddressButton +import chat.simplex.app.views.chat.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.usersettings.SettingsActionItem +import kotlinx.datetime.Clock @Composable fun GroupMemberInfoView( groupInfo: GroupInfo, member: GroupMember, connStats: ConnectionStats?, + connectionCode: String?, chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit, // Close all open windows up to ChatView @@ -48,6 +52,7 @@ fun GroupMemberInfoView( connStats, newRole, developerTools, + connectionCode, getContactChat = { chatModel.getContactChat(it) }, knownDirectChat = { withApi { @@ -90,6 +95,32 @@ fun GroupMemberInfoView( }, switchMemberAddress = { switchMemberAddress(chatModel, groupInfo, member) + }, + verifyClicked = { + ModalManager.shared.showModalCloseable { close -> + remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + VerifyCodeView( + mem.displayName, + connectionCode, + mem.verified, + verify = { code -> + chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r -> + val (verified, existingCode) = r + chatModel.upsertGroupMember( + groupInfo, + mem.copy( + activeConn = mem.activeConn?.copy( + connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null + ) + ) + ) + r + } + }, + close, + ) + } + } } ) } @@ -119,12 +150,14 @@ fun GroupMemberInfoLayout( connStats: ConnectionStats?, newRole: MutableState, developerTools: Boolean, + connectionCode: String?, getContactChat: (Long) -> Chat?, knownDirectChat: (Chat) -> Unit, newDirectChat: (Long) -> Unit, removeMember: () -> Unit, onRoleSelected: (GroupMemberRole) -> Unit, switchMemberAddress: () -> Unit, + verifyClicked: () -> Unit, ) { Column( Modifier @@ -143,18 +176,24 @@ fun GroupMemberInfoLayout( if (member.memberActive) { val contactId = member.memberContactId if (contactId != null) { - val chat = getContactChat(contactId) - if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) { - SectionView { + SectionView { + val chat = getContactChat(contactId) + if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) { OpenChatButton(onClick = { knownDirectChat(chat) }) - } - SectionSpacer() - } else if (groupInfo.fullGroupPreferences.directMessages.on) { - SectionView { + if (connectionCode != null) { + SectionDivider() + } + } else if (groupInfo.fullGroupPreferences.directMessages.on) { OpenChatButton(onClick = { newDirectChat(contactId) }) + if (connectionCode != null) { + SectionDivider() + } + } + if (connectionCode != null) { + VerifyCodeButton(member.verified, verifyClicked) } - SectionSpacer() } + SectionSpacer() } } @@ -179,10 +218,10 @@ fun GroupMemberInfoLayout( } } SectionSpacer() - SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) { + if (connStats != null) { + SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) { SwitchAddressButton(switchMemberAddress) SectionDivider() - if (connStats != null) { val rcvServers = connStats.rcvServers val sndServers = connStats.sndServers if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) { @@ -197,8 +236,8 @@ fun GroupMemberInfoLayout( } } } + SectionSpacer() } - SectionSpacer() if (member.canBeRemoved(groupInfo)) { SectionView { @@ -225,12 +264,17 @@ fun GroupMemberInfoHeader(member: GroupMember) { horizontalAlignment = Alignment.CenterHorizontally ) { ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) - Text( - member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), - color = MaterialTheme.colors.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight) + } + Text( + member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + color = MaterialTheme.colors.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } if (member.fullName != "" && member.fullName != member.displayName) { Text( member.fullName, style = MaterialTheme.typography.h2, @@ -320,12 +364,14 @@ fun PreviewGroupMemberInfoLayout() { connStats = null, newRole = remember { mutableStateOf(GroupMemberRole.Member) }, developerTools = false, + connectionCode = "123", getContactChat = { Chat.sampleData }, knownDirectChat = {}, newDirectChat = {}, removeMember = {}, onRoleSelected = {}, switchMemberAddress = {}, + verifyClicked = {}, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 11fb038766..680619a4d4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -6,8 +6,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -63,11 +62,21 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD ) } + @Composable + fun VerifiedIcon() { + Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight) + } + @Composable fun chatPreviewTitle() { when (cInfo) { is ChatInfo.Direct -> - chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight) + Row(verticalAlignment = Alignment.CenterVertically) { + if (cInfo.contact.verified) { + VerifiedIcon() + } + chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight) + } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary) diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index c227420408..afa07daeb0 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -250,6 +250,8 @@ Pending Switch receiving address? This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member). + View security code + Verify security code Send Message @@ -385,6 +387,19 @@ One-time invitation link Your contact address + + Scan code + Incorrect security code! + Scan security code from your contact\'s app. + + + Security code + Mark verified + Clear verification + To verify end-to-end encryption with your contact compare (or scan) the code on your devices. + %s is verified + %s is not verified + Your settings Your SimpleX contact address