From dcedbac379c6b10164f4898024c4134413b251c2 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 10 Jul 2023 19:01:51 +0400 Subject: [PATCH] android: ratchet synchronization (#2666) * android: ratchet synchronization * member info * item * icons * update contact keeping stats * update members keep stats * update texts --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 74 +++++- .../java/chat/simplex/app/model/SimpleXAPI.kt | 106 +++++++- .../simplex/app/views/chat/ChatInfoView.kt | 81 +++++- .../chat/simplex/app/views/chat/ChatView.kt | 79 +++++- .../app/views/chat/group/GroupChatInfoView.kt | 3 +- .../views/chat/group/GroupMemberInfoView.kt | 49 +++- .../views/chat/item/CIRcvDecryptionError.kt | 236 ++++++++++++++++-- .../app/views/chat/item/ChatItemView.kt | 21 +- .../app/views/chatlist/ChatListNavLinkView.kt | 13 +- .../commonMain/resources/MR/base/strings.xml | 24 +- .../commonMain/resources/MR/cs/strings.xml | 2 +- .../commonMain/resources/MR/de/strings.xml | 2 +- .../commonMain/resources/MR/es/strings.xml | 2 +- .../commonMain/resources/MR/fi/strings.xml | 2 +- .../commonMain/resources/MR/fr/strings.xml | 2 +- .../resources/MR/images/ic_sync_problem.svg | 1 + .../resources/MR/images/ic_warning.svg | 1 + .../commonMain/resources/MR/it/strings.xml | 2 +- .../commonMain/resources/MR/iw/strings.xml | 2 +- .../commonMain/resources/MR/ja/strings.xml | 2 +- .../commonMain/resources/MR/nl/strings.xml | 2 +- .../commonMain/resources/MR/pl/strings.xml | 2 +- .../resources/MR/pt-rBR/strings.xml | 2 +- .../commonMain/resources/MR/ru/strings.xml | 2 +- .../commonMain/resources/MR/th/strings.xml | 2 +- .../commonMain/resources/MR/uk/strings.xml | 2 +- .../resources/MR/zh-rCN/strings.xml | 2 +- .../resources/MR/zh-rTW/strings.xml | 2 +- 28 files changed, 656 insertions(+), 64 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt index f7ff5a80c8..0851f32e28 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration -import chat.simplex.app.R import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* import chat.simplex.app.views.chat.ComposeState @@ -131,13 +130,36 @@ object ChatModel { fun updateChatInfo(cInfo: ChatInfo) { val i = getChatIndex(cInfo.id) - if (i >= 0) chats[i] = chats[i].copy(chatInfo = cInfo) + if (i >= 0) { + val currentCInfo = chats[i].chatInfo + var newCInfo = cInfo + if (currentCInfo is ChatInfo.Direct && newCInfo is ChatInfo.Direct) { + val currentStats = currentCInfo.contact.activeConn.connectionStats + val newStats = newCInfo.contact.activeConn.connectionStats + if (currentStats != null && newStats == null) { + newCInfo = newCInfo.copy( + contact = newCInfo.contact.copy( + activeConn = newCInfo.contact.activeConn.copy( + connectionStats = currentStats + ) + ) + ) + } + } + chats[i] = chats[i].copy(chatInfo = newCInfo) + } } fun updateContactConnection(contactConnection: PendingContactConnection) = updateChat(ChatInfo.ContactConnection(contactConnection)) fun updateContact(contact: Contact) = updateChat(ChatInfo.Direct(contact), addMissing = contact.directOrUsed) + fun updateContactConnectionStats(contact: Contact, connectionStats: ConnectionStats) { + val updatedConn = contact.activeConn.copy(connectionStats = connectionStats) + val updatedContact = contact.copy(activeConn = updatedConn) + updateContact(updatedContact) + } + fun updateGroup(groupInfo: GroupInfo) = updateChat(ChatInfo.Group(groupInfo)) private fun updateChat(cInfo: ChatInfo, addMissing: Boolean = true) { @@ -436,6 +458,15 @@ object ChatModel { } } + fun updateGroupMemberConnectionStats(groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) { + val memberConn = member.activeConn + if (memberConn != null) { + val updatedConn = memberConn.copy(connectionStats = connectionStats) + val updatedMember = member.copy(activeConn = updatedConn) + upsertGroupMember(groupInfo, updatedMember) + } + } + fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) { networkStatuses[contact.activeConn.agentConnId] = status } @@ -747,7 +778,7 @@ data class Contact( override val id get() = "@$contactId" override val apiId get() = contactId override val ready get() = activeConn.connStatus == ConnStatus.Ready - override val sendMsgEnabled get() = true + override val sendMsgEnabled get() = !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false) override val ntfsEnabled get() = chatSettings.enableNtfs override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { @@ -827,7 +858,8 @@ data class Connection( val connLevel: Int, val viaGroupLink: Boolean, val customUserProfileId: Long? = null, - val connectionCode: SecurityCode? = null + val connectionCode: SecurityCode? = null, + val connectionStats: ConnectionStats? = null ) { val id: ChatId get() = ":$connId" companion object { @@ -1768,11 +1800,15 @@ sealed class CIContent: ItemContent { @Serializable enum class MsgDecryptError { @SerialName("ratchetHeader") RatchetHeader, - @SerialName("tooManySkipped") TooManySkipped; + @SerialName("tooManySkipped") TooManySkipped, + @SerialName("ratchetEarlier") RatchetEarlier, + @SerialName("other") Other; val text: String get() = when (this) { RatchetHeader -> generalGetString(MR.strings.decryption_error) TooManySkipped -> generalGetString(MR.strings.decryption_error) + RatchetEarlier -> generalGetString(MR.strings.decryption_error) + Other -> generalGetString(MR.strings.decryption_error) } } @@ -2328,18 +2364,33 @@ sealed class SndGroupEvent() { @Serializable sealed class RcvConnEvent { @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase): RcvConnEvent() + @Serializable @SerialName("ratchetSync") class RatchetSync(val syncStatus: RatchetSyncState): RcvConnEvent() + @Serializable @SerialName("verificationCodeReset") object VerificationCodeReset: RcvConnEvent() val text: String get() = when (this) { is SwitchQueue -> when (phase) { SwitchPhase.Completed -> generalGetString(MR.strings.rcv_conn_event_switch_queue_phase_completed) else -> generalGetString(MR.strings.rcv_conn_event_switch_queue_phase_changing) } + is RatchetSync -> ratchetSyncStatusToText(syncStatus) + is VerificationCodeReset -> generalGetString(MR.strings.rcv_conn_event_verification_code_reset) + } +} + +fun ratchetSyncStatusToText(ratchetSyncStatus: RatchetSyncState): String { + return when (ratchetSyncStatus) { + RatchetSyncState.Ok -> generalGetString(MR.strings.conn_event_ratchet_sync_ok) + RatchetSyncState.Allowed -> generalGetString(MR.strings.conn_event_ratchet_sync_allowed) + RatchetSyncState.Required -> generalGetString(MR.strings.conn_event_ratchet_sync_required) + RatchetSyncState.Started -> generalGetString(MR.strings.conn_event_ratchet_sync_started) + RatchetSyncState.Agreed -> generalGetString(MR.strings.conn_event_ratchet_sync_agreed) } } @Serializable sealed class SndConnEvent { @Serializable @SerialName("switchQueue") class SwitchQueue(val phase: SwitchPhase, val member: GroupMemberRef? = null): SndConnEvent() + @Serializable @SerialName("ratchetSync") class RatchetSync(val syncStatus: RatchetSyncState, val member: GroupMemberRef? = null): SndConnEvent() val text: String get() = when (this) { @@ -2355,6 +2406,19 @@ sealed class SndConnEvent { else -> generalGetString(MR.strings.snd_conn_event_switch_queue_phase_changing) } } + + is RatchetSync -> { + member?.profile?.profileViewName?.let { + return when (syncStatus) { + RatchetSyncState.Ok -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_ok), it) + RatchetSyncState.Allowed -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_allowed), it) + RatchetSyncState.Required -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_required), it) + RatchetSyncState.Started -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_started), it) + RatchetSyncState.Agreed -> String.format(generalGetString(MR.strings.snd_conn_event_ratchet_sync_agreed), it) + } + } + ratchetSyncStatusToText(syncStatus) + } } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index f7d7de42b8..d97b1ae57d 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -717,9 +717,9 @@ object ChatController { return null } - suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): ConnectionStats? { + suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(CC.APIGroupMemberInfo(groupId, groupMemberId)) - if (r is CR.GroupMemberInfo) return r.connectionStats_ + if (r is CR.GroupMemberInfo) return Pair(r.member, r.connectionStats_) Log.e(TAG, "apiGroupMemberInfo bad response: ${r.responseType} ${r.details}") return null } @@ -731,9 +731,9 @@ object ChatController { return null } - suspend fun apiSwitchGroupMember(groupId: Long, groupMemberId: Long): ConnectionStats? { + suspend fun apiSwitchGroupMember(groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(CC.APISwitchGroupMember(groupId, groupMemberId)) - if (r is CR.GroupMemberSwitchStarted) return r.connectionStats + if (r is CR.GroupMemberSwitchStarted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiSwitchGroupMember", generalGetString(MR.strings.error_changing_address), r) return null } @@ -745,13 +745,27 @@ object ChatController { return null } - suspend fun apiAbortSwitchGroupMember(groupId: Long, groupMemberId: Long): ConnectionStats? { + suspend fun apiAbortSwitchGroupMember(groupId: Long, groupMemberId: Long): Pair? { val r = sendCmd(CC.APIAbortSwitchGroupMember(groupId, groupMemberId)) - if (r is CR.GroupMemberSwitchAborted) return r.connectionStats + if (r is CR.GroupMemberSwitchAborted) return Pair(r.member, r.connectionStats) apiErrorAlert("apiAbortSwitchGroupMember", generalGetString(MR.strings.error_aborting_address_change), r) return null } + suspend fun apiSyncContactRatchet(contactId: Long, force: Boolean): ConnectionStats? { + val r = sendCmd(CC.APISyncContactRatchet(contactId, force)) + if (r is CR.ContactRatchetSyncStarted) return r.connectionStats + apiErrorAlert("apiSyncContactRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) + return null + } + + suspend fun apiSyncGroupMemberRatchet(groupId: Long, groupMemberId: Long, force: Boolean): Pair? { + val r = sendCmd(CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)) + if (r is CR.GroupMemberRatchetSyncStarted) return Pair(r.member, r.connectionStats) + apiErrorAlert("apiSyncGroupMemberRatchet", generalGetString(MR.strings.error_synchronizing_connection), r) + return null + } + suspend fun apiGetContactCode(contactId: Long): Pair { val r = sendCmd(CC.APIGetContactCode(contactId)) if (r is CR.ContactCode) return r.contact to r.connectionCode @@ -1569,6 +1583,14 @@ object ChatController { } } } + is CR.ContactSwitch -> + chatModel.updateContactConnectionStats(r.contact, r.switchProgress.connectionStats) + is CR.GroupMemberSwitch -> + chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.switchProgress.connectionStats) + is CR.ContactRatchetSync -> + chatModel.updateContactConnectionStats(r.contact, r.ratchetSyncProgress.connectionStats) + is CR.GroupMemberRatchetSync -> + chatModel.updateGroupMemberConnectionStats(r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) else -> Log.d(TAG , "unsupported event: ${r.responseType}") } @@ -1789,6 +1811,8 @@ sealed class CC { class APISwitchGroupMember(val groupId: Long, val groupMemberId: Long): CC() class APIAbortSwitchContact(val contactId: Long): CC() class APIAbortSwitchGroupMember(val groupId: Long, val groupMemberId: Long): CC() + class APISyncContactRatchet(val contactId: Long, val force: Boolean): CC() + class APISyncGroupMemberRatchet(val groupId: Long, val groupMemberId: Long, val force: Boolean): 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() @@ -1884,6 +1908,8 @@ sealed class CC { is APISwitchGroupMember -> "/_switch #$groupId $groupMemberId" is APIAbortSwitchContact -> "/_abort switch @$contactId" is APIAbortSwitchGroupMember -> "/_abort switch #$groupId $groupMemberId" + is APISyncContactRatchet -> if (force) "/_sync @$contactId force=on" else "/_sync @$contactId" + is APISyncGroupMemberRatchet -> if (force) "/_sync #$groupId $groupMemberId force=on" else "/_sync #$groupId $groupMemberId" is APIGetContactCode -> "/_get code @$contactId" is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId" is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else "" @@ -1974,6 +2000,8 @@ sealed class CC { is APISwitchGroupMember -> "apiSwitchGroupMember" is APIAbortSwitchContact -> "apiAbortSwitchContact" is APIAbortSwitchGroupMember -> "apiAbortSwitchGroupMember" + is APISyncContactRatchet -> "apiSyncContactRatchet" + is APISyncGroupMemberRatchet -> "apiSyncGroupMemberRatchet" is APIGetContactCode -> "apiGetContactCode" is APIGetGroupMemberCode -> "apiGetGroupMemberCode" is APIVerifyContact -> "apiVerifyContact" @@ -3161,6 +3189,14 @@ sealed class CR { @Serializable @SerialName("groupMemberSwitchStarted") class GroupMemberSwitchStarted(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR() @Serializable @SerialName("contactSwitchAborted") class ContactSwitchAborted(val user: User, val contact: Contact, val connectionStats: ConnectionStats): CR() @Serializable @SerialName("groupMemberSwitchAborted") class GroupMemberSwitchAborted(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR() + @Serializable @SerialName("contactSwitch") class ContactSwitch(val user: User, val contact: Contact, val switchProgress: SwitchProgress): CR() + @Serializable @SerialName("groupMemberSwitch") class GroupMemberSwitch(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val switchProgress: SwitchProgress): CR() + @Serializable @SerialName("contactRatchetSyncStarted") class ContactRatchetSyncStarted(val user: User, val contact: Contact, val connectionStats: ConnectionStats): CR() + @Serializable @SerialName("groupMemberRatchetSyncStarted") class GroupMemberRatchetSyncStarted(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats: ConnectionStats): CR() + @Serializable @SerialName("contactRatchetSync") class ContactRatchetSync(val user: User, val contact: Contact, val ratchetSyncProgress: RatchetSyncProgress): CR() + @Serializable @SerialName("groupMemberRatchetSync") class GroupMemberRatchetSync(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val ratchetSyncProgress: RatchetSyncProgress): CR() + @Serializable @SerialName("contactVerificationReset") class ContactVerificationReset(val user: User, val contact: Contact): CR() + @Serializable @SerialName("groupMemberVerificationReset") class GroupMemberVerificationReset(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("contactCode") class ContactCode(val user: User, val contact: Contact, val connectionCode: String): CR() @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: User, val verified: Boolean, val expectedCode: String): CR() @@ -3279,6 +3315,14 @@ sealed class CR { is GroupMemberSwitchStarted -> "groupMemberSwitchStarted" is ContactSwitchAborted -> "contactSwitchAborted" is GroupMemberSwitchAborted -> "groupMemberSwitchAborted" + is ContactSwitch -> "contactSwitch" + is GroupMemberSwitch -> "groupMemberSwitch" + is ContactRatchetSyncStarted -> "contactRatchetSyncStarted" + is GroupMemberRatchetSyncStarted -> "groupMemberRatchetSyncStarted" + is ContactRatchetSync -> "contactRatchetSync" + is GroupMemberRatchetSync -> "groupMemberRatchetSync" + is ContactVerificationReset -> "contactVerificationReset" + is GroupMemberVerificationReset -> "groupMemberVerificationReset" is ContactCode -> "contactCode" is GroupMemberCode -> "groupMemberCode" is ConnectionVerified -> "connectionVerified" @@ -3394,6 +3438,14 @@ sealed class CR { is GroupMemberSwitchStarted -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats)}") is ContactSwitchAborted -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}") is GroupMemberSwitchAborted -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats)}") + is ContactSwitch -> withUser(user, "contact: ${json.encodeToString(contact)}\nswitchProgress: ${json.encodeToString(switchProgress)}") + is GroupMemberSwitch -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nswitchProgress: ${json.encodeToString(switchProgress)}") + is ContactRatchetSyncStarted -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}") + is GroupMemberRatchetSyncStarted -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats)}") + is ContactRatchetSync -> withUser(user, "contact: ${json.encodeToString(contact)}\nratchetSyncProgress: ${json.encodeToString(ratchetSyncProgress)}") + is GroupMemberRatchetSync -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nratchetSyncProgress: ${json.encodeToString(ratchetSyncProgress)}") + is ContactVerificationReset -> withUser(user, "contact: ${json.encodeToString(contact)}") + is GroupMemberVerificationReset -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}") is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") @@ -3525,7 +3577,19 @@ abstract class TerminalItem { } @Serializable -class ConnectionStats(val rcvQueuesInfo: List, val sndQueuesInfo: List) +class ConnectionStats( + val connAgentVersion: Int, + val rcvQueuesInfo: List, + val sndQueuesInfo: List, + val ratchetSyncState: RatchetSyncState, + val ratchetSyncSupported: Boolean +) { + val ratchetSyncAllowed: Boolean get() = + ratchetSyncSupported && listOf(RatchetSyncState.Allowed, RatchetSyncState.Required).contains(ratchetSyncState) + + val ratchetSyncSendProhibited: Boolean get() = + listOf(RatchetSyncState.Required, RatchetSyncState.Started, RatchetSyncState.Agreed).contains(ratchetSyncState) +} @Serializable class RcvQueueInfo( @@ -3554,6 +3618,34 @@ enum class SndSwitchStatus { @SerialName("sending_qtest") SendingQTEST } +@Serializable +enum class QueueDirection { + @SerialName("rcv") Rcv, + @SerialName("snd") Snd +} + +@Serializable +class SwitchProgress( + val queueDirection: QueueDirection, + val switchPhase: SwitchPhase, + val connectionStats: ConnectionStats +) + +@Serializable +class RatchetSyncProgress( + val ratchetSyncStatus: RatchetSyncState, + val connectionStats: ConnectionStats +) + +@Serializable +enum class RatchetSyncState { + @SerialName("ok") Ok, + @SerialName("allowed") Allowed, + @SerialName("required") Required, + @SerialName("started") Started, + @SerialName("agreed") Agreed +} + @Serializable class UserContactLinkRec(val connReqContact: String, val autoAccept: AutoAccept? = null) { val responseDetails: String get() = "connReqContact: ${connReqContact}\nautoAccept: ${AutoAccept.cmdString(autoAccept)}" diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index f7e28ff365..2a7c889fcd 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.text.style.TextAlign 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.SimplexApp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* @@ -84,14 +83,45 @@ fun ChatInfoView( switchContactAddress = { showSwitchAddressAlert(switchAddress = { withApi { - connStats.value = chatModel.controller.apiSwitchContact(contact.contactId) + val cStats = chatModel.controller.apiSwitchContact(contact.contactId) + connStats.value = cStats + if (cStats != null) { + chatModel.updateContactConnectionStats(contact, cStats) + } + close.invoke() } }) }, abortSwitchContactAddress = { showAbortSwitchAddressAlert(abortSwitchAddress = { withApi { - connStats.value = chatModel.controller.apiAbortSwitchContact(contact.contactId) + val cStats = chatModel.controller.apiAbortSwitchContact(contact.contactId) + connStats.value = cStats + if (cStats != null) { + chatModel.updateContactConnectionStats(contact, cStats) + } + } + }) + }, + syncContactConnection = { + withApi { + val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = false) + connStats.value = cStats + if (cStats != null) { + chatModel.updateContactConnectionStats(contact, cStats) + } + close.invoke() + } + }, + syncContactConnectionForce = { + showSyncConnectionForceAlert(syncConnectionForce = { + withApi { + val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = true) + connStats.value = cStats + if (cStats != null) { + chatModel.updateContactConnectionStats(contact, cStats) + } + close.invoke() } }) }, @@ -179,6 +209,8 @@ fun ChatInfoLayout( clearChat: () -> Unit, switchContactAddress: () -> Unit, abortSwitchContactAddress: () -> Unit, + syncContactConnection: () -> Unit, + syncContactConnectionForce: () -> Unit, verifyClicked: () -> Unit, ) { val cStats = connStats.value @@ -208,6 +240,11 @@ fun ChatInfoLayout( VerifyCodeButton(contact.verified, verifyClicked) } ContactPreferencesButton(openPreferences) + if (cStats != null && cStats.ratchetSyncAllowed) { + SynchronizeConnectionButton(syncContactConnection) + } else if (developerTools) { + SynchronizeConnectionButtonForce(syncContactConnectionForce) + } } SectionDividerSpaced() @@ -230,12 +267,12 @@ fun ChatInfoLayout( } if (cStats != null) { SwitchAddressButton( - disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }, + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null } || cStats.ratchetSyncSendProhibited, switchAddress = switchContactAddress ) if (cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }) { AbortSwitchAddressButton( - disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch }, + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch } || cStats.ratchetSyncSendProhibited, abortSwitchAddress = abortSwitchContactAddress ) } @@ -421,6 +458,28 @@ fun AbortSwitchAddressButton(disabled: Boolean, abortSwitchAddress: () -> Unit) } } +@Composable +fun SynchronizeConnectionButton(syncConnection: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_sync_problem), + stringResource(MR.strings.fix_connection), + click = syncConnection, + textColor = WarningOrange, + iconColor = WarningOrange + ) +} + +@Composable +fun SynchronizeConnectionButtonForce(syncConnectionForce: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_warning), + stringResource(MR.strings.renegotiate_encryption), + click = syncConnectionForce, + textColor = Color.Red, + iconColor = Color.Red + ) +} + @Composable fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) { SettingsActionItem( @@ -498,6 +557,16 @@ fun showAbortSwitchAddressAlert(abortSwitchAddress: () -> Unit) { ) } +fun showSyncConnectionForceAlert(syncConnectionForce: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.sync_connection_force_question), + text = generalGetString(MR.strings.sync_connection_force_desc), + confirmText = generalGetString(MR.strings.sync_connection_force_confirm), + onConfirm = syncConnectionForce, + destructive = true, + ) +} + @Preview @Composable fun PreviewChatInfoLayout() { @@ -520,6 +589,8 @@ fun PreviewChatInfoLayout() { clearChat = {}, switchContactAddress = {}, abortSwitchContactAddress = {}, + syncContactConnection = {}, + syncContactConnectionForce = {}, verifyClicked = {}, ) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 6f7b61725c..d728f47a7e 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* import androidx.core.content.FileProvider import chat.simplex.app.* -import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* @@ -167,7 +166,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) withApi { - val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val stats = r?.second val (_, code) = if (member.memberActive) { try { chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) @@ -260,6 +260,47 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { chatModel.controller.allowFeatureToContact(contact, feature, param) } }, + updateContactStats = { contact -> + withApi { + val r = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) + if (r != null) { + chatModel.updateContactConnectionStats(contact, r.first) + } + } + }, + updateMemberStats = { groupInfo, member -> + withApi { + val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + if (r != null) { + val memStats = r.second + if (memStats != null) { + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, memStats) + } + } + } + }, + syncContactConnection = { contact -> + withApi { + val cStats = chatModel.controller.apiSyncContactRatchet(contact.contactId, force = false) + if (cStats != null) { + chatModel.updateContactConnectionStats(contact, cStats) + } + } + }, + syncMemberConnection = { groupInfo, member -> + withApi { + val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = false) + if (r != null) { + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + } + } + }, + findModelChat = { chatId -> + chatModel.getChat(chatId) + }, + findModelMember = { memberId -> + chatModel.groupMembers.find { it.id == memberId } + }, setReaction = { cInfo, cItem, add, reaction -> withApi { val updatedCI = chatModel.controller.apiChatItemReaction( @@ -344,6 +385,12 @@ fun ChatLayout( startCall: (CallMediaType) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, + updateContactStats: (Contact) -> Unit, + updateMemberStats: (GroupInfo, GroupMember) -> Unit, + syncContactConnection: (Contact) -> Unit, + syncMemberConnection: (GroupInfo, GroupMember) -> Unit, + findModelChat: (String) -> Chat?, + findModelMember: (String) -> GroupMember?, setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, addMembers: (GroupInfo) -> Unit, @@ -388,7 +435,9 @@ fun ChatLayout( ChatItemsList( chat, unreadCount, composeState, chatItems, searchValue, useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage, - receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, setReaction, showItemDetails, markRead, setFloatingButton, onComposed, + receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, + updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, + setReaction, showItemDetails, markRead, setFloatingButton, onComposed, ) } } @@ -561,6 +610,12 @@ fun BoxWithConstraintsScope.ChatItemsList( joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, + updateContactStats: (Contact) -> Unit, + updateMemberStats: (GroupInfo, GroupMember) -> Unit, + syncContactConnection: (Contact) -> Unit, + syncMemberConnection: (GroupInfo, GroupMember) -> Unit, + findModelChat: (String) -> Chat?, + findModelMember: (String) -> GroupMember?, setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, @@ -676,11 +731,11 @@ fun BoxWithConstraintsScope.ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) + ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) } } else { Box(Modifier.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp).then(swipeableModifier)) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) } } } else { // direct message @@ -691,7 +746,7 @@ fun BoxWithConstraintsScope.ChatItemsList( end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, ).then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) } } @@ -1114,6 +1169,12 @@ fun PreviewChatLayout() { startCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, + updateContactStats = { }, + updateMemberStats = { _, _ -> }, + syncContactConnection = { }, + syncMemberConnection = { _, _ -> }, + findModelChat = { null }, + findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, @@ -1176,6 +1237,12 @@ fun PreviewGroupChatLayout() { startCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, + updateContactStats = { }, + updateMemberStats = { _, _ -> }, + syncContactConnection = { }, + syncMemberConnection = { _, _ -> }, + findModelChat = { null }, + findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt index a2c8d21684..a731006bad 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt @@ -61,7 +61,8 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR }, showMemberInfo = { member -> withApi { - val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val r = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + val stats = r?.second val (_, code) = if (member.memberActive) { try { chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt index 8c17466312..f8fca83c92 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt @@ -102,14 +102,46 @@ fun GroupMemberInfoView( switchMemberAddress = { showSwitchAddressAlert(switchAddress = { withApi { - connStats.value = chatModel.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + val r = chatModel.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + if (r != null) { + connStats.value = r.second + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + close.invoke() + } } }) }, abortSwitchMemberAddress = { showAbortSwitchAddressAlert(abortSwitchAddress = { withApi { - connStats.value = chatModel.controller.apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + val r = chatModel.controller.apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + if (r != null) { + connStats.value = r.second + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + close.invoke() + } + } + }) + }, + syncMemberConnection = { + withApi { + val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = false) + if (r != null) { + connStats.value = r.second + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + close.invoke() + } + } + }, + syncMemberConnectionForce = { + showSyncConnectionForceAlert(syncConnectionForce = { + withApi { + val r = chatModel.controller.apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force = true) + if (r != null) { + connStats.value = r.second + chatModel.updateGroupMemberConnectionStats(groupInfo, r.first, r.second) + close.invoke() + } } }) }, @@ -176,6 +208,8 @@ fun GroupMemberInfoLayout( onRoleSelected: (GroupMemberRole) -> Unit, switchMemberAddress: () -> Unit, abortSwitchMemberAddress: () -> Unit, + syncMemberConnection: () -> Unit, + syncMemberConnectionForce: () -> Unit, verifyClicked: () -> Unit, ) { val cStats = connStats.value @@ -212,6 +246,11 @@ fun GroupMemberInfoLayout( if (connectionCode != null) { VerifyCodeButton(member.verified, verifyClicked) } + if (cStats != null && cStats.ratchetSyncAllowed) { + SynchronizeConnectionButton(syncMemberConnection) + } else if (developerTools) { + SynchronizeConnectionButtonForce(syncMemberConnectionForce) + } } SectionDividerSpaced() } @@ -254,12 +293,12 @@ fun GroupMemberInfoLayout( SectionDividerSpaced() SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { SwitchAddressButton( - disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }, + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null } || cStats.ratchetSyncSendProhibited, switchAddress = switchMemberAddress ) if (cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }) { AbortSwitchAddressButton( - disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch }, + disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch } || cStats.ratchetSyncSendProhibited, abortSwitchAddress = abortSwitchMemberAddress ) } @@ -414,6 +453,8 @@ fun PreviewGroupMemberInfoLayout() { onRoleSelected = {}, switchMemberAddress = {}, abortSwitchMemberAddress = {}, + syncMemberConnection = {}, + syncMemberConnectionForce = {}, verifyClicked = {}, ) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIRcvDecryptionError.kt index 80e4ea15ba..060c94028e 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIRcvDecryptionError.kt @@ -1,25 +1,231 @@ package chat.simplex.app.views.chat.item -import androidx.compose.runtime.Composable -import chat.simplex.app.R +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontStyle +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import chat.simplex.app.model.* -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.app.ui.theme.CurrentColors +import chat.simplex.app.views.helpers.* import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource @Composable -fun CIRcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean) { - CIMsgError(ci, timedMessagesTTL, showMember) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.decryption_error), - text = when (msgDecryptError) { - MsgDecryptError.RatchetHeader -> String.format(generalGetString(MR.strings.alert_text_decryption_error_header), msgCount.toLong()) + "\n" + - generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" + - generalGetString(MR.strings.alert_text_fragment_permanent_error_reconnect) - MsgDecryptError.TooManySkipped -> String.format(generalGetString(MR.strings.alert_text_decryption_error_too_many_skipped), msgCount.toLong()) + "\n" + - generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" + - generalGetString(MR.strings.alert_text_fragment_permanent_error_reconnect) +fun CIRcvDecryptionError( + msgDecryptError: MsgDecryptError, + msgCount: UInt, + cInfo: ChatInfo, + ci: ChatItem, + updateContactStats: (Contact) -> Unit, + updateMemberStats: (GroupInfo, GroupMember) -> Unit, + syncContactConnection: (Contact) -> Unit, + syncMemberConnection: (GroupInfo, GroupMember) -> Unit, + findModelChat: (String) -> Chat?, + findModelMember: (String) -> GroupMember?, + showMember: Boolean +) { + LaunchedEffect(Unit) { + if (cInfo is ChatInfo.Direct) { + updateContactStats(cInfo.contact) + } else if (cInfo is ChatInfo.Group && ci.chatDir is CIDirection.GroupRcv) { + updateMemberStats(cInfo.groupInfo, ci.chatDir.groupMember) + } + } + + @Composable + fun BasicDecryptionErrorItem() { + DecryptionErrorItem( + ci, + showMember, + onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.decryption_error), + text = alertMessage(msgDecryptError, msgCount) + ) } ) } + + if (cInfo is ChatInfo.Direct) { + val modelCInfo = findModelChat(cInfo.id)?.chatInfo + if (modelCInfo is ChatInfo.Direct) { + val modelContactStats = modelCInfo.contact.activeConn.connectionStats + if (modelContactStats != null) { + if (modelContactStats.ratchetSyncAllowed) { + DecryptionErrorItemFixButton( + ci, + showMember, + onClick = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.fix_connection_question), + text = alertMessage(msgDecryptError, msgCount), + confirmText = generalGetString(MR.strings.fix_connection_confirm), + onConfirm = { syncContactConnection(cInfo.contact) }, + ) + }, + syncSupported = true + ) + } else if (!modelContactStats.ratchetSyncSupported) { + DecryptionErrorItemFixButton( + ci, + showMember, + onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.fix_connection_not_supported_by_contact), + text = alertMessage(msgDecryptError, msgCount) + ) + }, + syncSupported = false + ) + } else { + BasicDecryptionErrorItem() + } + } else { + BasicDecryptionErrorItem() + } + } else { + BasicDecryptionErrorItem() + } + } else if (cInfo is ChatInfo.Group && ci.chatDir is CIDirection.GroupRcv) { + val modelMember = findModelMember(ci.chatDir.groupMember.id) + val modelMemberStats = modelMember?.activeConn?.connectionStats + if (modelMemberStats != null) { + if (modelMemberStats.ratchetSyncAllowed) { + DecryptionErrorItemFixButton( + ci, + showMember, + onClick = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.fix_connection_question), + text = alertMessage(msgDecryptError, msgCount), + confirmText = generalGetString(MR.strings.fix_connection_confirm), + onConfirm = { syncMemberConnection(cInfo.groupInfo, modelMember) }, + ) + }, + syncSupported = true + ) + } else if (!modelMemberStats.ratchetSyncSupported) { + DecryptionErrorItemFixButton( + ci, + showMember, + onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.fix_connection_not_supported_by_group_member), + text = alertMessage(msgDecryptError, msgCount) + ) + }, + syncSupported = false + ) + } else { + BasicDecryptionErrorItem() + } + } else { + BasicDecryptionErrorItem() + } + } else { + BasicDecryptionErrorItem() + } +} + +@Composable +fun DecryptionErrorItemFixButton( + ci: ChatItem, + showMember: Boolean, + onClick: () -> Unit, + syncSupported: Boolean +) { + val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage + Surface( + Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = receivedColor, + ) { + Box( + Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + contentAlignment = Alignment.BottomEnd, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + buildAnnotatedString { + appendSender(this, if (showMember) ci.memberDisplayName else null, true) + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) + ) + Row { + Icon( + painterResource(MR.images.ic_sync_problem), + stringResource(MR.strings.fix_connection), + tint = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + Spacer(Modifier.padding(2.dp)) + Text( + buildAnnotatedString { + append(generalGetString(MR.strings.fix_connection)) + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) } + withStyle(reserveTimestampStyle) { append(" ") } // for icon + }, + color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } + } + CIMetaView(ci, timedMessagesTTL = null) + } + } +} + +@Composable +fun DecryptionErrorItem( + ci: ChatItem, + showMember: Boolean, + onClick: () -> Unit +) { + val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage + Surface( + Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = receivedColor, + ) { + Box( + Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + contentAlignment = Alignment.BottomEnd, + ) { + Text( + buildAnnotatedString { + appendSender(this, if (showMember) ci.memberDisplayName else null, true) + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) + ) + CIMetaView(ci, timedMessagesTTL = null) + } + } +} + +private fun alertMessage(msgDecryptError: MsgDecryptError, msgCount: UInt): String { + return when (msgDecryptError) { + MsgDecryptError.RatchetHeader -> String.format(generalGetString(MR.strings.alert_text_decryption_error_n_messages_failed_to_decrypt), msgCount.toLong()) + "\n" + + generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + + MsgDecryptError.TooManySkipped -> String.format(generalGetString(MR.strings.alert_text_decryption_error_too_many_skipped), msgCount.toLong()) + "\n" + + generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + + MsgDecryptError.RatchetEarlier -> String.format(generalGetString(MR.strings.alert_text_decryption_error_n_messages_failed_to_decrypt), msgCount.toLong()) + "\n" + + generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + + MsgDecryptError.Other -> String.format(generalGetString(MR.strings.alert_text_decryption_error_n_messages_failed_to_decrypt), msgCount.toLong()) + "\n" + + generalGetString(MR.strings.alert_text_fragment_encryption_out_of_sync_old_database) + } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 93a4670dfe..d67921d3dd 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.text.font.FontWeight 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.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.* @@ -47,6 +46,12 @@ fun ChatItemView( acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, + updateContactStats: (Contact) -> Unit, + updateMemberStats: (GroupInfo, GroupMember) -> Unit, + syncContactConnection: (Contact) -> Unit, + syncMemberConnection: (GroupInfo, GroupMember) -> Unit, + findModelChat: (String) -> Chat?, + findModelMember: (String) -> GroupMember?, setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, ) { @@ -302,7 +307,7 @@ fun ChatItemView( is CIContent.SndCall -> CallItem(c.status, c.duration) is CIContent.RcvCall -> CallItem(c.status, c.duration) is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember) - is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember) + is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, showMember = showMember) is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.RcvGroupEventContent -> CIEventView(cItem) @@ -522,6 +527,12 @@ fun PreviewChatItemView() { acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, + updateContactStats = { }, + updateMemberStats = { _, _ -> }, + syncContactConnection = { }, + syncMemberConnection = { _, _ -> }, + findModelChat = { null }, + findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, ) @@ -545,6 +556,12 @@ fun PreviewChatItemViewDeletedContent() { acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, + updateContactStats = { }, + updateMemberStats = { _, _ -> }, + syncContactConnection = { }, + syncMemberConnection = { _, _ -> }, + findModelChat = { null }, + findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, ) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index 4f3294706b..c923b2fe14 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -134,8 +134,19 @@ suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: St suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) { val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId) + val currentMembers = chatModel.groupMembers + val newMembers = groupMembers.map { newMember -> + val currentMember = currentMembers.find { it.id == newMember.id } + val currentMemberStats = currentMember?.activeConn?.connectionStats + val newMemberConn = newMember.activeConn + if (currentMemberStats != null && newMemberConn != null && newMemberConn.connectionStats == null) { + newMember.copy(activeConn = newMemberConn.copy(connectionStats = currentMemberStats)) + } else { + newMember + } + } chatModel.groupMembers.clear() - chatModel.groupMembers.addAll(groupMembers) + chatModel.groupMembers.addAll(newMembers) } @Composable diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 75a6bf3546..1797890271 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -104,6 +104,7 @@ Error deleting pending contact connection Error changing address Error aborting address change + Error synchronizing connection Test failed at step %s. Server requires authorization to create queues, check password Server requires authorization to upload, check password @@ -339,6 +340,9 @@ Abort changing address? Address change will be aborted. Old receiving address will be used. Abort + Renegotiate encryption? + The encryption is working and the new encryption agreement is not required. It may result in connection errors! + Renegotiate View security code Verify security code @@ -816,10 +820,9 @@ The hash of the previous message is different." Bad message ID The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised. - %1$d messages failed to decrypt. + %1$d messages failed to decrypt. %1$d messages skipped. It can happen when you or your connection used the old database backup. - This error is permanent for this connection, please re-connect. Please report it to the developers. @@ -1065,6 +1068,17 @@ changing address for %s… you changed address changing address… + encryption ok + encryption re-negotiation allowed + encryption re-negotiation required + agreeing encryption… + encryption agreed + encryption ok for %s + encryption re-negotiation allowed for %s + encryption re-negotiation required for %s + agreeing encryption for %s + encryption agreed for %s + security code changed observer @@ -1184,6 +1198,12 @@ Network status Change receiving address Abort changing address + Fix connection + Fix connection? + Fix + Fix not supported by contact + Fix not supported by group member + Renegotiate encryption Create secret group diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 431b494d29..d5aa11739e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1096,7 +1096,7 @@ Hash předchozí zprávy se liší. ID další zprávy je nesprávné (menší nebo rovno předchozí). \nMůže se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitováno. - %1$d zprávy se nepodařilo dešifrovat. + %1$d zprávy se nepodařilo dešifrovat. %1$d zprývy přeskočeny. Nahlaste to prosím vývojářům. Tato chyba je pro toto připojení trvalá, připojte se znovu. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 97402b4871..c44133812e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1173,7 +1173,7 @@ XFTP-Server Die ID der nächsten Nachricht ist falsch (kleiner oder gleich der Vorherigen). \nDies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompromittiert wurde. - %1$d Nachrichten konnten nicht entschlüsselt werden. + %1$d Nachrichten konnten nicht entschlüsselt werden. Der Hash der vorherigen Nachricht unterscheidet sich. Sie können die SimpleX Sperre über die Einstellungen aktivieren. SOCKS-Proxy Einstellungen diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index a4c2f6614e..b7415a3b97 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1101,7 +1101,7 @@ Puede ocurrir cuando tu o tu contacto estáis usando una copia de seguridad antigua de la base de datos. El hash del mensaje anterior es diferente. El error es permanente para esta conexión, por favor vuelve a conectarte. - %1$d mensajes no pudieron ser descifrados. + %1$d mensajes no pudieron ser descifrados. ¡Sin espacios! Detener archivo El archivo será eliminado de los servidores. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index 39b860d455..c576c7bece 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -1244,7 +1244,7 @@ %1$d viestit ohitettu. Seuraavan viestin tunnus on väärä (pienempi tai yhtä suuri kuin edellisen). \nTämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut. - %1$d viestien salauksen purku epäonnistui. + %1$d viestien salauksen purku epäonnistui. Näytä Sinun on annettava tunnuslause aina, kun sovellus käynnistyy - sitä ei tallenneta laitteeseen. Päivitä tietokannan tunnuslause diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 325b02ecfd..7ffc6aca85 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1119,7 +1119,7 @@ Arrêter de recevoir le fichier \? Cette erreur est persistante pour cette connexion, veuillez vous reconnecter. Vous n\'avez pas pu être vérifié·e ; veuillez réessayer. - %1$d messages n\'ont pas pu être déchiffrés. + %1$d messages n\'ont pas pu être déchiffrés. %1$d messages sautés. Vous pouvez activer SimpleX Lock dans les Paramètres. Merci aux utilisateurs - contribuez via Weblate ! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg new file mode 100644 index 0000000000..79a0441792 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg new file mode 100644 index 0000000000..7148c5740b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 2fdc66e335..fe4eb5be6c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1097,7 +1097,7 @@ L\'ID del messaggio successivo non è corretto (inferiore o uguale al precedente). \nPuò accadere a causa di qualche bug o quando la connessione è compromessa. L\'errore è permanente per questa connessione, riconnettiti. - %1$d messaggi non decifrati. + %1$d messaggi non decifrati. Hash del messaggio errato L\'hash del messaggio precedente è diverso. Si prega di segnalarlo agli sviluppatori. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index a9f4d8e190..6392861799 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1197,7 +1197,7 @@ דרך ממסר וידאו כבוי וידאו פעיל - %1$d הודעות לא הצליחו לעבור פענוח. + %1$d הודעות לא הצליחו לעבור פענוח. עליכם להשתמש בגרסה העדכנית ביותר של מסד הנתונים שלכם במכשיר אחד בלבד, אחרת אתם עלולים להפסיק לקבל הודעות מאנשי קשר מסוימים. סיסמה שגויה! הוזמנתם לקבוצה. הצטרפו כדי ליצור קשר עם חברי הקבוצה. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 48dc894f1f..b72dabf154 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -1072,7 +1072,7 @@ 開発者に報告してください。 このエラーはこの接続では永続的なものです。再接続してください。 1GBまでのビデオとファイル - %1$d メッセージの復号化に失敗しました。 + %1$d メッセージの復号化に失敗しました。 %1$d メッセージをスキップしました SMP サーバーのロード中にエラーが発生しました ユーザーパスワード保存エラー diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 3697081620..5a3500c902 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1099,7 +1099,7 @@ Onjuiste bericht ID De ID van het volgende bericht is onjuist (minder of gelijk aan het vorige). \nHet kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. - %1$d-berichten konden niet worden ontsleuteld. + %1$d-berichten konden niet worden ontsleuteld. Geen spaties! Het ontvangen van het bestand wordt gestopt. Intrekken diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 552f41ffe9..f5a6258891 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1096,7 +1096,7 @@ Hash poprzedniej wiadomości jest inny. Identyfikator następnej wiadomości jest nieprawidłowy (mniejszy lub równy poprzedniej). \nMoże się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skompromitowane. - Nie udało się odszyfrować %1$d wiadomości. + Nie udało się odszyfrować %1$d wiadomości. Ten błąd jest trwały dla tego połączenia, proszę o ponowne połączenie. %1$d pominiętych wiadomości. Może się to zdarzyć, gdy Ty lub Twoje połączenie użyło starej kopii zapasowej bazy danych. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index ccd12968c1..0a1868092e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -1088,7 +1088,7 @@ Você pode ativar o bloqueio SimpleX via Configurações. Hash de mensagem incorreta O hash da mensagem anterior é diferente. - %1$d descriptografia das mensagens falhou + %1$d descriptografia das mensagens falhou ID de mensagem incorreta A ID da próxima mensagem está incorreta (menor ou igual à anterior). \nIsso pode acontecer por causa de algum bug ou quando a conexão está comprometida. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index e7a82a1146..77903a4d7b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1156,7 +1156,7 @@ Аутентификация отменена Системная %d секунд - %1$d сообщений не удалось расшифровать. + %1$d сообщений не удалось расшифровать. Ошибка расшифровки Блокировка SimpleX не включена! Ошибка хэш сообщения diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index 3f704f2a9f..bf2073c480 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -1052,7 +1052,7 @@ ตรวจสอบความปลอดภัยในการเชื่อมต่อ เมื่อคุณแชร์โปรไฟล์ที่ไม่ระบุตัวตนกับใครสักคน โปรไฟล์นี้จะใช้สำหรับกลุ่มที่พวกเขาเชิญคุณ %1$s ต้องการเชื่อมต่อกับคุณผ่านทาง - ข้อความ %1$d ไม่สามารถ decrypt ได้ + ข้อความ %1$d ไม่สามารถ decrypt ได้ %1$s สมาชิก เชื่อมต่อกับ SimpleX Chat นักพัฒนาแอปเพื่อถามคำถามและรับการอัปเดต]]> คุณสามารถแชร์ที่อยู่ของคุณเป็นลิงก์หรือรหัสคิวอาร์ - ใคร ๆ ก็สามารถเชื่อมต่อกับคุณได้ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 3641fa2b3e..eb45409d56 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -874,7 +874,7 @@ Показати Вимкнути Сервер ретрансляції використовується лише за необхідності. Інша сторона може спостерігати за вашою IP-адресою. - %1$d повідомлення не вдалося розшифрувати. + %1$d повідомлення не вдалося розшифрувати. %1$d повідомлення пропущені. Будь ласка, повідомте про це розробникам. Надіслати попередній перегляд за посиланням diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index e90a1e4f60..105750cf2b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1092,7 +1092,7 @@ 立即 错误消息散列 错误消息 ID - %1$d 消息解密失败。 + %1$d 消息解密失败。 %1$d 已跳过消息。 此错误对于此连接是永久性的,请重新连接。 当您或您的连接使用旧数据库备份时,可能会发生这种情况。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index 8b28a432d1..65337b4df7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -1120,7 +1120,7 @@ 系統 此ID的下一則訊息是錯誤(小於或等於上一則的)。 \n當一些錯誤出現或你的連結被破壞時會發生。 - %1$d 訊息解密失敗。 + %1$d 訊息解密失敗。 使用SOCKS 代理伺服器 你的 XFTP 伺服器 這個連結錯誤是永久性的,請重新連接。