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>
This commit is contained in:
spaced4ndy
2023-07-10 19:01:51 +04:00
committed by GitHub
parent a6a87cb7de
commit dcedbac379
28 changed files with 656 additions and 64 deletions

View File

@@ -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)
}
}
}

View File

@@ -717,9 +717,9 @@ object ChatController {
return null
}
suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): ConnectionStats? {
suspend fun apiGroupMemberInfo(groupId: Long, groupMemberId: Long): Pair<GroupMember, ConnectionStats?>? {
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<GroupMember, ConnectionStats>? {
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<GroupMember, ConnectionStats>? {
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<GroupMember, ConnectionStats>? {
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<Contact, String> {
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<RcvQueueInfo>, val sndQueuesInfo: List<SndQueueInfo>)
class ConnectionStats(
val connAgentVersion: Int,
val rcvQueuesInfo: List<RcvQueueInfo>,
val sndQueuesInfo: List<SndQueueInfo>,
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)}"

View File

@@ -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 = {},
)
}

View File

@@ -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 = { _ -> },

View File

@@ -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)

View File

@@ -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 = {},
)
}

View File

@@ -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)
}
}

View File

@@ -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 = { _, _ -> },
)

View File

@@ -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

View File

@@ -104,6 +104,7 @@
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
<string name="error_changing_address">Error changing address</string>
<string name="error_aborting_address_change">Error aborting address change</string>
<string name="error_synchronizing_connection">Error synchronizing connection</string>
<string name="error_smp_test_failed_at_step">Test failed at step %s.</string>
<string name="error_smp_test_server_auth">Server requires authorization to create queues, check password</string>
<string name="error_xftp_test_server_auth">Server requires authorization to upload, check password</string>
@@ -339,6 +340,9 @@
<string name="abort_switch_receiving_address_question">Abort changing address?</string>
<string name="abort_switch_receiving_address_desc">Address change will be aborted. Old receiving address will be used.</string>
<string name="abort_switch_receiving_address_confirm">Abort</string>
<string name="sync_connection_force_question">Renegotiate encryption?</string>
<string name="sync_connection_force_desc">The encryption is working and the new encryption agreement is not required. It may result in connection errors!</string>
<string name="sync_connection_force_confirm">Renegotiate</string>
<string name="view_security_code">View security code</string>
<string name="verify_security_code">Verify security code</string>
@@ -816,10 +820,9 @@
<string name="alert_text_msg_bad_hash">The hash of the previous message is different."</string>
<string name="alert_title_msg_bad_id">Bad message ID</string>
<string name="alert_text_msg_bad_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.</string>
<string name="alert_text_decryption_error_header">%1$d messages failed to decrypt.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d messages failed to decrypt.</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d messages skipped.</string>
<string name="alert_text_fragment_encryption_out_of_sync_old_database">It can happen when you or your connection used the old database backup.</string>
<string name="alert_text_fragment_permanent_error_reconnect">This error is permanent for this connection, please re-connect.</string>
<string name="alert_text_fragment_please_report_to_developers">Please report it to the developers.</string>
<!-- Privacy settings -->
@@ -1065,6 +1068,17 @@
<string name="snd_conn_event_switch_queue_phase_changing_for_member">changing address for %s…</string>
<string name="snd_conn_event_switch_queue_phase_completed">you changed address</string>
<string name="snd_conn_event_switch_queue_phase_changing">changing address…</string>
<string name="conn_event_ratchet_sync_ok">encryption ok</string>
<string name="conn_event_ratchet_sync_allowed">encryption re-negotiation allowed</string>
<string name="conn_event_ratchet_sync_required">encryption re-negotiation required</string>
<string name="conn_event_ratchet_sync_started">agreeing encryption…</string>
<string name="conn_event_ratchet_sync_agreed">encryption agreed</string>
<string name="snd_conn_event_ratchet_sync_ok">encryption ok for %s</string>
<string name="snd_conn_event_ratchet_sync_allowed">encryption re-negotiation allowed for %s</string>
<string name="snd_conn_event_ratchet_sync_required">encryption re-negotiation required for %s</string>
<string name="snd_conn_event_ratchet_sync_started">agreeing encryption for %s</string>
<string name="snd_conn_event_ratchet_sync_agreed">encryption agreed for %s</string>
<string name="rcv_conn_event_verification_code_reset">security code changed</string>
<!-- GroupMemberRole -->
<string name="group_member_role_observer">observer</string>
@@ -1184,6 +1198,12 @@
<string name="network_status">Network status</string>
<string name="switch_receiving_address">Change receiving address</string>
<string name="abort_switch_receiving_address">Abort changing address</string>
<string name="fix_connection">Fix connection</string>
<string name="fix_connection_question">Fix connection?</string>
<string name="fix_connection_confirm">Fix</string>
<string name="fix_connection_not_supported_by_contact">Fix not supported by contact</string>
<string name="fix_connection_not_supported_by_group_member">Fix not supported by group member</string>
<string name="renegotiate_encryption">Renegotiate encryption</string>
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Create secret group</string>

View File

@@ -1096,7 +1096,7 @@
<string name="alert_text_msg_bad_hash">Hash předchozí zprávy se liší.</string>
<string name="alert_text_msg_bad_id">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.</string>
<string name="alert_text_decryption_error_header">%1$d zprávy se nepodařilo dešifrovat.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d zprávy se nepodařilo dešifrovat.</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d zprývy přeskočeny.</string>
<string name="alert_text_fragment_please_report_to_developers">Nahlaste to prosím vývojářům.</string>
<string name="alert_text_fragment_permanent_error_reconnect">Tato chyba je pro toto připojení trvalá, připojte se znovu.</string>

View File

@@ -1173,7 +1173,7 @@
<string name="xftp_servers">XFTP-Server</string>
<string name="alert_text_msg_bad_id">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.</string>
<string name="alert_text_decryption_error_header">%1$d Nachrichten konnten nicht entschlüsselt werden.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d Nachrichten konnten nicht entschlüsselt werden.</string>
<string name="alert_text_msg_bad_hash">Der Hash der vorherigen Nachricht unterscheidet sich.</string>
<string name="you_can_turn_on_lock">Sie können die SimpleX Sperre über die Einstellungen aktivieren.</string>
<string name="network_socks_proxy_settings">SOCKS-Proxy Einstellungen</string>

View File

@@ -1101,7 +1101,7 @@
<string name="alert_text_fragment_encryption_out_of_sync_old_database">Puede ocurrir cuando tu o tu contacto estáis usando una copia de seguridad antigua de la base de datos.</string>
<string name="alert_text_msg_bad_hash">El hash del mensaje anterior es diferente.</string>
<string name="alert_text_fragment_permanent_error_reconnect">El error es permanente para esta conexión, por favor vuelve a conectarte.</string>
<string name="alert_text_decryption_error_header">%1$d mensajes no pudieron ser descifrados.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d mensajes no pudieron ser descifrados.</string>
<string name="no_spaces">¡Sin espacios!</string>
<string name="stop_file__action">Detener archivo</string>
<string name="revoke_file__message">El archivo será eliminado de los servidores.</string>

View File

@@ -1244,7 +1244,7 @@
<string name="alert_text_decryption_error_too_many_skipped"> %1$d viestit ohitettu.</string>
<string name="alert_text_msg_bad_id">Seuraavan viestin tunnus on väärä (pienempi tai yhtä suuri kuin edellisen).
\nTämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.</string>
<string name="alert_text_decryption_error_header">%1$d viestien salauksen purku epäonnistui.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d viestien salauksen purku epäonnistui.</string>
<string name="user_unhide">Näytä</string>
<string name="you_have_to_enter_passphrase_every_time">Sinun on annettava tunnuslause aina, kun sovellus käynnistyy - sitä ei tallenneta laitteeseen.</string>
<string name="update_database_passphrase">Päivitä tietokannan tunnuslause</string>

View File

@@ -1119,7 +1119,7 @@
<string name="stop_rcv_file__title">Arrêter de recevoir le fichier \?</string>
<string name="alert_text_fragment_permanent_error_reconnect">Cette erreur est persistante pour cette connexion, veuillez vous reconnecter.</string>
<string name="la_could_not_be_verified">Vous n\'avez pas pu être vérifié·e; veuillez réessayer.</string>
<string name="alert_text_decryption_error_header">%1$d messages n\'ont pas pu être déchiffrés.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d messages n\'ont pas pu être déchiffrés.</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d messages sautés.</string>
<string name="you_can_turn_on_lock">Vous pouvez activer SimpleX Lock dans les Paramètres.</string>
<string name="v5_0_polish_interface_descr">Merci aux utilisateurs - contribuez via Weblate !</string>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-284.5q-11.5 0-20-8.75t-8.5-20.25q0-11 8.5-19.75t20-8.75q11.5 0 20.25 8.75T509-313.5q0 11.5-8.75 20.25T480-284.5Zm2.325-145.5q-12.325 0-20.575-8.375T453.5-459v-189q0-11.75 8.425-20.125 8.426-8.375 20.75-8.375 11.825 0 20.075 8.375T511-648v189q0 12.25-8.425 20.625-8.426 8.375-20.25 8.375ZM190.5-477q0 65.183 28 118.591 28 53.409 90 93.409v-96q0-11.75 8.425-20.125 8.426-8.375 20.75-8.375 11.825 0 20.075 8.375T366-361v168.5q0 12.25-8.375 20.625T337.5-163.5H169q-12.25 0-20.625-8.425-8.375-8.426-8.375-20.75 0-11.825 8.375-20.075T169-221h101.5q-64.5-48-101-108T133-477q0-88 46-165.75T311-762.5q10-4.5 20.25-.25T346-748q4 11 .25 21.75T332.5-711q-62.5 32-102.25 94.881Q190.5-553.237 190.5-477Zm589.5-6.5q0-64.683-28.25-118.091Q723.5-655 661.5-695v95.5q0 12.25-8.425 20.625-8.426 8.375-20.25 8.375-12.325 0-20.575-8.375T604-599.5V-768q0-11.75 8.375-20.125T633-796.5h168.5q11.75 0 20.125 8.425 8.375 8.426 8.375 20.25 0 12.325-8.375 20.575T801.5-739H700q64 48 100.75 108t36.75 147.5q0 88.5-46.25 166.75T659-197q-10 5-20.25.25T624.5-212q-4.5-10.5-.75-21.5t14.25-16q62-31.5 102-94.381 40-62.882 40-139.619Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M96.63-124.5q-8.63 0-15.282-4.125Q74.696-132.75 71.25-139q-3.917-6.1-4.333-13.55Q66.5-160 71.5-168l383.936-661.885Q460-837.5 466.25-841t13.75-3.5q7.5 0 13.75 3.5T505-830l384 662q4.5 8 4.083 15.45-.416 7.45-4.333 13.55-3.446 6.25-10.098 10.375Q872-124.5 863.37-124.5H96.63ZM146.5-182h667L480-758 146.5-182Zm337.728-57.5q12.272 0 20.522-8.478 8.25-8.478 8.25-20.75t-8.478-20.522q-8.478-8.25-20.75-8.25t-20.522 8.478q-8.25 8.478-8.25 20.75t8.478 20.522q8.478 8.25 20.75 8.25Zm.197-108.5q12.075 0 20.325-8.375T513-377v-165q0-11.675-8.463-20.088-8.463-8.412-20.212-8.412-12.325 0-20.575 8.412-8.25 8.413-8.25 20.088v165q0 12.25 8.425 20.625 8.426 8.375 20.5 8.375ZM480-470Z"/></svg>

After

Width:  |  Height:  |  Size: 774 B

View File

@@ -1097,7 +1097,7 @@
<string name="alert_text_msg_bad_id">L\'ID del messaggio successivo non è corretto (inferiore o uguale al precedente).
\nPuò accadere a causa di qualche bug o quando la connessione è compromessa.</string>
<string name="alert_text_fragment_permanent_error_reconnect">L\'errore è permanente per questa connessione, riconnettiti.</string>
<string name="alert_text_decryption_error_header">%1$d messaggi non decifrati.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d messaggi non decifrati.</string>
<string name="alert_title_msg_bad_hash">Hash del messaggio errato</string>
<string name="alert_text_msg_bad_hash">L\'hash del messaggio precedente è diverso.</string>
<string name="alert_text_fragment_please_report_to_developers">Si prega di segnalarlo agli sviluppatori.</string>

View File

@@ -1197,7 +1197,7 @@
<string name="call_connection_via_relay">דרך ממסר</string>
<string name="icon_descr_video_off">וידאו כבוי</string>
<string name="icon_descr_video_on">וידאו פעיל</string>
<string name="alert_text_decryption_error_header">%1$d הודעות לא הצליחו לעבור פענוח.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d הודעות לא הצליחו לעבור פענוח.</string>
<string name="you_must_use_the_most_recent_version_of_database">עליכם להשתמש בגרסה העדכנית ביותר של מסד הנתונים שלכם במכשיר אחד בלבד, אחרת אתם עלולים להפסיק לקבל הודעות מאנשי קשר מסוימים.</string>
<string name="wrong_passphrase_title">סיסמה שגויה!</string>
<string name="you_are_invited_to_group_join_to_connect_with_group_members">הוזמנתם לקבוצה. הצטרפו כדי ליצור קשר עם חברי הקבוצה.</string>

View File

@@ -1072,7 +1072,7 @@
<string name="alert_text_fragment_please_report_to_developers">開発者に報告してください。</string>
<string name="alert_text_fragment_permanent_error_reconnect">このエラーはこの接続では永続的なものです。再接続してください。</string>
<string name="v5_0_large_files_support">1GBまでのビデオとファイル</string>
<string name="alert_text_decryption_error_header">%1$d メッセージの復号化に失敗しました。</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d メッセージの復号化に失敗しました。</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d メッセージをスキップしました</string>
<string name="error_loading_smp_servers">SMP サーバーのロード中にエラーが発生しました</string>
<string name="error_saving_user_password">ユーザーパスワード保存エラー</string>

View File

@@ -1099,7 +1099,7 @@
<string name="alert_title_msg_bad_id">Onjuiste bericht ID</string>
<string name="alert_text_msg_bad_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.</string>
<string name="alert_text_decryption_error_header">%1$d-berichten konden niet worden ontsleuteld.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d-berichten konden niet worden ontsleuteld.</string>
<string name="no_spaces">Geen spaties!</string>
<string name="stop_rcv_file__message">Het ontvangen van het bestand wordt gestopt.</string>
<string name="revoke_file__confirm">Intrekken</string>

View File

@@ -1096,7 +1096,7 @@
<string name="alert_text_msg_bad_hash">Hash poprzedniej wiadomości jest inny.</string>
<string name="alert_text_msg_bad_id">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.</string>
<string name="alert_text_decryption_error_header">Nie udało się odszyfrować %1$d wiadomości.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">Nie udało się odszyfrować %1$d wiadomości.</string>
<string name="alert_text_fragment_permanent_error_reconnect">Ten błąd jest trwały dla tego połączenia, proszę o ponowne połączenie.</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d pominiętych wiadomości.</string>
<string name="alert_text_fragment_encryption_out_of_sync_old_database">Może się to zdarzyć, gdy Ty lub Twoje połączenie użyło starej kopii zapasowej bazy danych.</string>

View File

@@ -1088,7 +1088,7 @@
<string name="you_can_turn_on_lock">Você pode ativar o bloqueio SimpleX via Configurações.</string>
<string name="alert_title_msg_bad_hash">Hash de mensagem incorreta</string>
<string name="alert_text_msg_bad_hash">O hash da mensagem anterior é diferente.</string>
<string name="alert_text_decryption_error_header">%1$d descriptografia das mensagens falhou</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d descriptografia das mensagens falhou</string>
<string name="alert_title_msg_bad_id">ID de mensagem incorreta</string>
<string name="alert_text_msg_bad_id">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.</string>

View File

@@ -1156,7 +1156,7 @@
<string name="authentication_cancelled">Аутентификация отменена</string>
<string name="la_mode_system">Системная</string>
<string name="la_seconds">%d секунд</string>
<string name="alert_text_decryption_error_header">%1$d сообщений не удалось расшифровать.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d сообщений не удалось расшифровать.</string>
<string name="decryption_error">Ошибка расшифровки</string>
<string name="lock_not_enabled">Блокировка SimpleX не включена!</string>
<string name="alert_title_msg_bad_hash">Ошибка хэш сообщения</string>

View File

@@ -1052,7 +1052,7 @@
<string name="v4_4_verify_connection_security">ตรวจสอบความปลอดภัยในการเชื่อมต่อ</string>
<string name="incognito_info_share">เมื่อคุณแชร์โปรไฟล์ที่ไม่ระบุตัวตนกับใครสักคน โปรไฟล์นี้จะใช้สำหรับกลุ่มที่พวกเขาเชิญคุณ</string>
<string name="contact_wants_to_connect_via_call">%1$s ต้องการเชื่อมต่อกับคุณผ่านทาง</string>
<string name="alert_text_decryption_error_header">ข้อความ %1$d ไม่สามารถ decrypt ได้</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">ข้อความ %1$d ไม่สามารถ decrypt ได้</string>
<string name="group_info_section_title_num_members">%1$s สมาชิก</string>
<string name="you_can_connect_to_simplex_chat_founder"><![CDATA[คุณสามารถ <font color="#0088ff">เชื่อมต่อกับ SimpleX Chat นักพัฒนาแอปเพื่อถามคำถามและรับการอัปเดต</font>]]></string>
<string name="you_can_share_your_address">คุณสามารถแชร์ที่อยู่ของคุณเป็นลิงก์หรือรหัสคิวอาร์ - ใคร ๆ ก็สามารถเชื่อมต่อกับคุณได้</string>

View File

@@ -874,7 +874,7 @@
<string name="show_call_on_lock_screen">Показати</string>
<string name="no_call_on_lock_screen">Вимкнути</string>
<string name="relay_server_if_necessary">Сервер ретрансляції використовується лише за необхідності. Інша сторона може спостерігати за вашою IP-адресою.</string>
<string name="alert_text_decryption_error_header">%1$d повідомлення не вдалося розшифрувати.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d повідомлення не вдалося розшифрувати.</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d повідомлення пропущені.</string>
<string name="alert_text_fragment_please_report_to_developers">Будь ласка, повідомте про це розробникам.</string>
<string name="send_link_previews">Надіслати попередній перегляд за посиланням</string>

View File

@@ -1092,7 +1092,7 @@
<string name="la_immediately">立即</string>
<string name="alert_title_msg_bad_hash">错误消息散列</string>
<string name="alert_title_msg_bad_id">错误消息 ID</string>
<string name="alert_text_decryption_error_header">%1$d 消息解密失败。</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d 消息解密失败。</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d 已跳过消息。</string>
<string name="alert_text_fragment_permanent_error_reconnect">此错误对于此连接是永久性的,请重新连接。</string>
<string name="alert_text_fragment_encryption_out_of_sync_old_database">当您或您的连接使用旧数据库备份时,可能会发生这种情况。</string>

View File

@@ -1120,7 +1120,7 @@
<string name="la_mode_system">系統</string>
<string name="alert_text_msg_bad_id">此ID的下一則訊息是錯誤小於或等於上一則的
\n當一些錯誤出現或你的連結被破壞時會發生。</string>
<string name="alert_text_decryption_error_header">%1$d 訊息解密失敗。</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d 訊息解密失敗。</string>
<string name="network_socks_toggle_use_socks_proxy">使用SOCKS 代理伺服器</string>
<string name="your_XFTP_servers">你的 XFTP 伺服器</string>
<string name="alert_text_fragment_permanent_error_reconnect">這個連結錯誤是永久性的,請重新連接。</string>