merge master into badge-in-app-checkout

This commit is contained in:
spaced4ndy
2026-10-08 14:31:30 +04:00
229 changed files with 8341 additions and 1977 deletions
@@ -0,0 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.ui.graphics.drawscope.DrawScope
// Android blurs the bars with a RenderEffect on the layer itself, so this is never called there.
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) = drawBar()
@@ -1,8 +1,11 @@
package chat.simplex.common.platform
import android.annotation.SuppressLint
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyInfo
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import chat.simplex.common.views.helpers.AlertManager
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
@@ -68,15 +71,44 @@ internal class Cryptor: CryptorInterface {
keyStore.deleteEntry(alias)
}
override fun keyStorage(alias: String): String? {
val secretKey = getSecretKey(alias) ?: return null
val keyInfo = SecretKeyFactory.getInstance(secretKey.algorithm, "AndroidKeyStore").getKeySpec(secretKey, KeyInfo::class.java) as KeyInfo
val storage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
when (keyInfo.securityLevel) {
KeyProperties.SECURITY_LEVEL_STRONGBOX -> MR.strings.keystore_key_storage_strongbox
KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> MR.strings.keystore_key_storage_tee
KeyProperties.SECURITY_LEVEL_SOFTWARE -> MR.strings.keystore_key_storage_software
else -> return null
}
} else {
// isInsideSecureHardware does not distinguish StrongBox, which createSecretKey only requests on API 31+
@Suppress("DEPRECATION")
if (keyInfo.isInsideSecureHardware) MR.strings.keystore_key_storage_tee else MR.strings.keystore_key_storage_software
}
return generalGetString(storage)
}
private fun createSecretKey(alias: String): SecretKey? {
if (keyStore.containsAlias(alias)) return getSecretKey(alias)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
return generateKey(keySpec(alias).setIsStrongBoxBacked(true))
} catch (e: StrongBoxUnavailableException) {
Log.i(TAG, "StrongBox is unavailable, using default keystore: ${e.message}")
}
}
return generateKey(keySpec(alias))
}
private fun keySpec(alias: String): KeyGenParameterSpec.Builder =
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
private fun generateKey(spec: KeyGenParameterSpec.Builder): SecretKey {
val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM, "AndroidKeyStore")
keyGenerator.init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
keyGenerator.init(spec.build())
return keyGenerator.generateKey()
}
@@ -132,7 +132,7 @@ actual fun ActiveCallView() {
}
Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCView: $apiMsg")
Log.d(TAG, "received from WebRTCView: ${apiMsg.resp.javaClass.simpleName}")
val call = chatModel.activeCall.value
if (call != null) {
val callState = call.androidCallState as ActiveCallState
@@ -711,7 +711,7 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
.collect {
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(wv, cmd)
}
@@ -785,7 +785,7 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
// onResponse(message)
onResponse(json.decodeFromString(message))
} catch (e: Exception) {
Log.e(TAG, "failed parsing WebView message: $message")
Log.e(TAG, "failed parsing WebView message")
}
}
}
@@ -59,6 +59,7 @@ actual fun DatabaseEncryptionFooter(
useKeychain: MutableState<Boolean>,
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
keyStorage: String?,
initialRandomDBPassphrase: MutableState<Boolean>,
migration: Boolean,
) {
@@ -66,7 +67,7 @@ actual fun DatabaseEncryptionFooter(
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely))
SectionTextFooter(String.format(generalGetString(MR.strings.keychain_is_storing_securely), keyStorage?.let { " ($it)" } ?: ""))
if (initialRandomDBPassphrase.value && !migration) {
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
} else {
@@ -207,7 +207,7 @@ fun MainScreen() {
SwitchingUsersView()
}
if (unauthorized.value && !(chatModel.activeCallViewIsVisible.value && chatModel.showCallView.value)) {
if (unauthorized.value) {
LaunchedEffect(Unit) {
// With these constrains when user presses back button while on ChatList, activity destroys and shows auth request
// while the screen moves to a launcher. Detect it and prevent showing the auth
@@ -221,7 +221,8 @@ fun MainScreen() {
SplashView(true)
ModalManager.fullscreen.showPasscodeInView()
}
} else {
}
if (!unauthorized.value || chatModel.activeCallViewIsVisible.value) {
if (chatModel.showCallView.value) {
if (appPlatform.isAndroid) {
LaunchedEffect(Unit) {
@@ -235,6 +236,8 @@ fun MainScreen() {
ActiveCallView()
}
}
}
if (!unauthorized.value) {
ModalManager.fullscreen.showOneTimePasscodeInView()
AlertManager.privacySensitive.showInView()
if (onboarding == OnboardingStage.OnboardingComplete) {
@@ -269,6 +269,11 @@ object AppLock {
fun elapsedRealtime(): Long = System.nanoTime() / 1_000_000
fun recheckAuthState() {
if (ChatModel.showCallView.value) {
// BiometricPrompt drops a prompt requested while MainActivity is stopped (call on lock screen), so request it again
if (userAuthorized.value == false) runAuthenticate()
return
}
val enteredBackgroundVal = enteredBackground.value
val delay = ChatController.appPrefs.laLockDelay.get()
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
@@ -3561,6 +3561,7 @@ data class ChatItem (
is RcvGroupEvent.MemberCreatedContact -> false
is RcvGroupEvent.MemberProfileUpdated -> false
is RcvGroupEvent.NewMemberPendingReview -> true
is RcvGroupEvent.MsgBadSignature -> false
}
is CIContent.SndGroupEventContent -> false
is CIContent.RcvConnEventContent -> false
@@ -5467,6 +5468,7 @@ sealed class RcvGroupEvent() {
@Serializable @SerialName("memberCreatedContact") class MemberCreatedContact(): RcvGroupEvent()
@Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent()
@Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent()
@Serializable @SerialName("msgBadSignature") class MsgBadSignature(): RcvGroupEvent()
val text: String get() = text(isChannel = false)
@@ -5491,6 +5493,7 @@ sealed class RcvGroupEvent() {
is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact)
is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile)
is NewMemberPendingReview -> generalGetString(MR.strings.rcv_group_event_new_member_pending_review)
is MsgBadSignature -> generalGetString(MR.strings.rcv_group_event_msg_bad_signature)
}
private fun profileUpdatedText(from: Profile, to: Profile): String =
@@ -34,6 +34,7 @@ import chat.simplex.common.views.chat.item.contentModerationPostLink
import chat.simplex.common.views.chat.item.showContentBlockedAlert
import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert
import chat.simplex.common.views.chatlist.openGroupChat
import chat.simplex.common.views.database.deleteDatabaseBackups
import chat.simplex.common.views.migration.MigrationFileLinkData
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.*
@@ -217,6 +218,7 @@ class AppPreferences {
val encryptedSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE, null)
val initializationVectorSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE, null)
val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null)
val shouldDeleteDatabaseBackups = mkBoolPreference(SHARED_PREFS_SHOULD_DELETE_DATABASE_BACKUPS, false)
val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false)
val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false)
val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null)
@@ -489,6 +491,7 @@ class AppPreferences {
private const val SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE = "EncryptedSelfDestructPassphrase"
private const val SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE = "InitializationVectorSelfDestructPassphrase"
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
private const val SHARED_PREFS_SHOULD_DELETE_DATABASE_BACKUPS = "ShouldDeleteDatabaseBackups"
private const val SHARED_PREFS_NEW_DATABASE_INITIALIZED = "NewDatabaseInitialized"
private const val SHARED_PREFS_SHOULD_IMPORT_APP_SETTINGS = "ShouldImportAppSettings"
private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades"
@@ -717,6 +720,10 @@ object ChatController {
apiStartChat()
appPrefs.chatStopped.set(false)
withLongRunningApi { BadgeStore.presentUnfinished() }
if (appPrefs.shouldDeleteDatabaseBackups.get()) {
deleteDatabaseBackups()
appPrefs.shouldDeleteDatabaseBackups.set(false)
}
} catch (e: Throwable) {
Log.e(TAG, "failed starting chat $e")
throw e
@@ -973,7 +980,7 @@ object ChatController {
val r = json.decodeFromString<API>(rStr)
if (log) {
Log.d(TAG, "sendCmd response type ${r.responseType}")
if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) {
if (r is API.Result && ((r.res is CR.Response && !r.res.type.startsWith("call")) || r.res is CR.Invalid)) {
Log.d(TAG, "sendCmd response json $rStr")
}
chatModel.addTerminalItem(TerminalItem.resp(rhId, r))
@@ -990,7 +997,7 @@ object ChatController {
} else {
val r = json.decodeFromString<API>(rStr)
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
if (r is API.Result && (r.res is CR.Response || r.res is CR.Invalid)) Log.d(TAG, "chatRecvMsg json: $rStr")
if (r is API.Result && ((r.res is CR.Response && !r.res.type.startsWith("call")) || r.res is CR.Invalid)) Log.d(TAG, "chatRecvMsg json: $rStr")
r
}
}
@@ -3396,21 +3403,13 @@ object ChatController {
chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId))
}
is CR.CallOffer -> {
// TODO askConfirmation?
// TODO check encryption is compatible
withCall(r, r.contact) { call ->
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, sharedKey = r.sharedKey)
val useRelay = appPrefs.webrtcPolicyRelay.get()
val iceServers = getIceServers()
Log.d(TAG, ".callOffer iceServers $iceServers")
chatModel.callCommand.add(WCallCommand.Offer(
offer = r.offer.rtcSession,
iceCandidates = r.offer.rtcIceCandidates,
media = r.callType.media,
aesKey = r.sharedKey,
iceServers = iceServers,
relay = useRelay
))
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, hasSharedKey = r.sharedKey != null)
if (r.askConfirmation) {
showUnencryptedCallAlert(call) { processCallOffer(r) }
} else {
processCallOffer(r)
}
}
}
is CR.CallAnswer -> {
@@ -3669,6 +3668,39 @@ object ChatController {
}
}
private fun processCallOffer(r: CR.CallOffer) {
val useRelay = appPrefs.webrtcPolicyRelay.get()
val iceServers = getIceServers()
chatModel.callCommand.add(WCallCommand.Offer(
offer = r.offer.rtcSession,
iceCandidates = r.offer.rtcIceCandidates,
media = r.callType.media,
aesKey = r.sharedKey,
iceServers = iceServers,
relay = useRelay
))
}
private fun showUnencryptedCallAlert(call: Call, onContinue: () -> Unit) {
// the call may have ended, or a new one started with the same contact, while the alert was shown
fun offerPending(): Boolean {
val c = chatModel.activeCall.value
return c != null && c.remoteHostId == call.remoteHostId && c.contact.id == call.contact.id && c.callState == CallState.OfferReceived
}
val endCall = { if (offerPending()) withBGApi { chatModel.callManager.endCall(call) } }
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.call_not_encrypted_title),
text = generalGetString(MR.strings.call_not_encrypted_desc).format(call.contact.displayName),
confirmText = generalGetString(MR.strings.call_service_notification_end_call),
onConfirm = { endCall() },
dismissText = generalGetString(MR.strings.continue_to_next_step),
onDismiss = { if (offerPending()) onContinue() },
onDismissRequest = { endCall() },
destructive = true,
parseHtml = false
)
}
suspend fun leaveGroup(rh: Long?, groupId: Long) {
val groupInfo = apiLeaveGroup(rh, groupId)
if (groupInfo != null) {
@@ -7280,9 +7312,9 @@ sealed class CR {
is SndStandaloneFileComplete -> withUser(user, rcvURIs.size.toString())
is SndFileError -> withUser(user, "errorMessage: ${json.encodeToString(errorMessage)}\nchatItem: ${json.encodeToString(chatItem_)}")
is SndFileWarning -> withUser(user, "errorMessage: ${json.encodeToString(errorMessage)}\nchatItem: ${json.encodeToString(chatItem_)}")
is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations)}"
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}"
is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}")
is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations.map { it.copy(sharedKey = null) })}"
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: ${callInvitation.callType}"
is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}")
is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}")
is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}")
is CallEnded -> withUser(user, "contact: ${contact.id}")
@@ -0,0 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.ui.graphics.drawscope.DrawScope
// Draws the bars' background blurred. The drawing to blur is given in bar coordinates, whatever size the blur runs at.
expect fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit)
@@ -4,6 +4,7 @@ interface CryptorInterface {
fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String?
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray>
fun deleteKey(alias: String)
fun keyStorage(alias: String): String?
}
expect val cryptor: CryptorInterface
@@ -1,18 +1,21 @@
package chat.simplex.common.platform
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.KeyChangeEffect
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filter
import java.io.File
import kotlin.math.roundToInt
@Composable
expect fun Modifier.desktopOnExternalDrag(
@@ -29,14 +32,14 @@ expect fun Modifier.desktopPointerHoverIconHand(): Modifier
expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier
@Composable
fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
fun Modifier.desktopModifyBlurredState(blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
if (appPlatform.isDesktop) {
KeyChangeEffect(blurRadius.value) {
blurred.value = enabled && blurRadius.value > 0
blurred.value = blurRadius.value > 0
}
}
return if (appPlatform.isDesktop && enabled && blurRadius.value > 0 && !showMenu.value) {
return if (appPlatform.isDesktop && blurRadius.value > 0 && !showMenu.value) {
var job: Job = remember { Job() }
LaunchedEffect(Unit) {
// The approach here is to allow menu to show up and to not blur the view. When menu is shown and mouse is hovering,
@@ -58,27 +61,37 @@ fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<B
}
}
@Composable
fun blurHidesMedia(enabled: Boolean, blurred: State<Boolean>): Boolean =
enabled && blurred.value && remember { appPrefs.privacyMediaBlurRadius.state }.value > 0
@Composable
fun Modifier.privacyBlur(
enabled: Boolean,
fullSize: Boolean,
preview: ImageBitmap,
blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
scrollState: State<Boolean>,
onLongClick: () -> Unit = {}
): Modifier {
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
return if (enabled && blurred.value) {
this then Modifier.blur(
radiusX = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
radiusY = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
edgeTreatment = BlurredEdgeTreatment(RoundedCornerShape(0.dp))
)
return if (blurHidesMedia(true, blurred)) {
this then (if (fullSize) {
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
Modifier.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
} else Modifier.drawWithCache {
val cropScale = maxOf(size.width / preview.width, size.height / preview.height)
val croppedSize = IntSize((preview.width * cropScale).roundToInt(), (preview.height * cropScale).roundToInt())
val blurredCropped = preview.blurredBy(blurRadius.value, croppedSize.width.toDp().value)
val offset = IntOffset(((size.width - croppedSize.width) / 2).roundToInt(), ((size.height - croppedSize.height) / 2).roundToInt())
onDrawWithContent { clipRect { drawImage(blurredCropped, dstOffset = offset, dstSize = croppedSize) } }
})
.combinedClickable(
onLongClick = onLongClick,
onClick = {
blurred.value = false
}
)
} else if (enabled && blurRadius.value > 0 && appPlatform.isAndroid) {
} else if (blurRadius.value > 0 && appPlatform.isAndroid) {
LaunchedEffect(Unit) {
snapshotFlow { scrollState.value }
.filter { it }
@@ -90,3 +103,27 @@ fun Modifier.privacyBlur(
this
}
}
// Calibrated so the resample blurs as much as Modifier.blur did at each radius; 360 read 5-75% stronger.
private const val BLURRED_MEDIA_WIDTH_DP = 400
// The width in-chat media is assumed to be drawn at; small views are blurred as much on screen.
private const val CHAT_MEDIA_WIDTH_DP = 360
// Bounds the first step: nothing bounds a decoded video frame, and reading every pixel of a 4K one would stall.
private const val RESAMPLE_MEDIA_FROM_SIDE = 512
private fun ImageBitmap.blurredBy(radius: Int, drawnWidthDp: Float = CHAT_MEDIA_WIDTH_DP.toFloat()): ImageBitmap {
if (width <= 0 || height <= 0) return this
val w = (BLURRED_MEDIA_WIDTH_DP * drawnWidthDp / CHAT_MEDIA_WIDTH_DP / radius).toInt().coerceIn(1, width)
val h = (w * height / width).coerceIn(1, BLURRED_MEDIA_WIDTH_DP)
val longest = maxOf(width, height)
var image = if (longest > RESAMPLE_MEDIA_FROM_SIDE) {
val step = RESAMPLE_MEDIA_FROM_SIDE.toFloat() / longest
scale((width * step).roundToInt().coerceAtLeast(1), (height * step).roundToInt().coerceAtLeast(1))
} else this
// One bilinear step from a large image undersamples it.
while (image.width / 2 > w) image = image.scale(image.width / 2, (image.height / 2).coerceAtLeast(1))
image = image.scale(w, h)
// Stretching w px straight to the screen shows their grid; each doubling smooths it, so the last stretch is small.
while (maxOf(image.width, image.height) < BLURRED_MEDIA_WIDTH_DP / 2) image = image.scale(image.width * 2, image.height * 2)
return image
}
@@ -1,5 +1,6 @@
package chat.simplex.common.platform
import chat.simplex.common.AppLock
import chat.simplex.common.model.*
import chat.simplex.common.views.call.RcvCallInvitation
import chat.simplex.common.views.chatlist.acceptContactRequest
@@ -96,6 +97,7 @@ abstract class NtfManager {
}
fun acceptCallAction(chatId: ChatId) {
AppLock.recheckAuthState()
chatModel.clearOverlays.value = true
val invitation = chatModel.callInvitations[chatId]
if (invitation == null) {
@@ -1,5 +1,6 @@
package chat.simplex.common.views.call
import chat.simplex.common.AppLock
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withBGApi
@@ -51,13 +52,12 @@ class CallManager(val chatModel: ChatModel) {
callUUID = invitation.callUUID,
callState = CallState.InvitationAccepted,
initialCallType = invitation.callType.media,
sharedKey = invitation.sharedKey,
hasSharedKey = invitation.sharedKey != null,
androidCallState = platform.androidCreateActiveCallState()
)
showCallView.value = true
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
val iceServers = getIceServers()
Log.d(TAG, "answerIncomingCall iceServers: $iceServers")
callCommand.add(WCallCommand.Start(
media = invitation.callType.media,
aesKey = invitation.sharedKey,
@@ -79,6 +79,7 @@ class CallManager(val chatModel: ChatModel) {
// Don't destroy WebView if you plan to accept next call right after this one
if (!switchingCall.value) {
AppLock.appWasHidden()
showCallView.value = false
activeCall.value?.androidCallState?.close()
activeCall.value = null
@@ -21,7 +21,7 @@ data class Call(
val localMediaSources: CallMediaSources = CallMediaSources(mic = true, camera = initialCallType == CallMediaType.Video),
val localCapabilities: CallCapabilities? = null,
val peerMediaSources: CallMediaSources = CallMediaSources(),
val sharedKey: String? = null,
val hasSharedKey: Boolean = false,
var localCamera: VideoCamera = VideoCamera.User,
val connectionInfo: ConnectionInfo? = null,
var connectedAt: Instant? = null,
@@ -32,14 +32,14 @@ data class Call(
val androidCallState: Closeable
) {
val encrypted: Boolean get() = localEncrypted && sharedKey != null
val encrypted: Boolean get() = localEncrypted && hasSharedKey
private val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
val encryptionStatus: String get() = when(callState) {
CallState.WaitCapabilities -> ""
CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption)
CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
CallState.InvitationAccepted -> generalGetString(if (!hasSharedKey) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (!hasSharedKey) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
}
val hasVideo: Boolean
@@ -1205,12 +1205,13 @@ fun SynchronizeConnectionButtonForce(syncConnectionForce: () -> Unit) {
}
@Composable
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit, disabled: Boolean = false) {
SettingsActionItem(
if (contactVerified) painterResource(MR.images.ic_verified_user) else painterResource(MR.images.ic_shield),
stringResource(if (contactVerified) MR.strings.view_security_code else MR.strings.verify_security_code),
click = onClick,
iconColor = MaterialTheme.colors.secondary,
disabled = disabled,
)
}
@@ -499,6 +499,15 @@ fun ChatView(
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
hideKeyboard(view)
groupMembersJob.cancel()
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
if (chatsCtx.secondaryContextFilter == null) {
ModalManager.end.closeModals()
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, close = close, closeAll = close)
}
groupMembersJob = scope.launch(Dispatchers.Default) {
val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
@@ -513,15 +522,9 @@ fun ChatView(
withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember)
}
if (chatsCtx.secondaryContextFilter == null) {
ModalManager.end.closeModals()
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close)
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
loadMessages = { chatId, pagination, visibleItemIndexes ->
@@ -2467,6 +2470,7 @@ fun BoxScope.ChatItemsList(
LaunchedEffect(Unit) {
snapshotFlow { listState.value.isScrollInProgress }
.onCompletion { chatViewScrollState.value = false }
.collect {
chatViewScrollState.value = it
}
@@ -123,6 +123,15 @@ fun ModalData.GroupChatInfoView(
}
},
showMemberInfo = { member, groupRelay ->
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
GroupMemberInfoView(rhId, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) {
closeCurrent()
close()
}
}
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
@@ -132,14 +141,9 @@ fun ModalData.GroupChatInfoView(
} else {
member to null
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) {
closeCurrent()
close()
}
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
editGroupProfile = {
@@ -45,10 +45,11 @@ import kotlinx.coroutines.*
fun GroupMemberInfoView(
rhId: Long?,
groupInfo: GroupInfo,
member: GroupMember,
groupMember: GroupMember,
scrollToItemId: MutableState<Long?>,
connectionStats: ConnectionStats?,
connectionCode: String?,
connStats: MutableState<ConnectionStats?>,
connectionCode: State<String?>,
connectionLoaded: State<Boolean>,
chatModel: ChatModel,
openedFromSupportChat: Boolean,
groupRelay: GroupRelay? = null,
@@ -60,7 +61,10 @@ fun GroupMemberInfoView(
}
BackHandler(onBack = close)
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId }
val connStats = remember { mutableStateOf(connectionStats) }
// the passed member is shown until the loaded one is added to the model, so that the profile opens without waiting for the core
val member = remember(groupMember.groupMemberId) {
derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember }
}.value
val developerTools = chatModel.controller.appPrefs.developerTools.get()
var progressIndicator by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -91,7 +95,8 @@ fun GroupMemberInfoView(
connStats,
newRole,
developerTools,
connectionCode,
connectionCode.value,
connectionLoaded.value,
groupRelay = groupRelay,
getContactChat = { chatModel.getContactChat(it) },
openDirectChat = { contactId ->
@@ -101,6 +106,7 @@ fun GroupMemberInfoView(
}
},
createMemberContact = {
val connectionStats = connStats.value
if (member.sendMsgEnabled) {
withBGApi {
progressIndicator = true
@@ -209,7 +215,7 @@ fun GroupMemberInfoView(
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,
connectionCode,
connectionCode.value,
mem.verified,
verify = { code ->
chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r ->
@@ -368,6 +374,7 @@ fun GroupMemberInfoLayout(
newRole: MutableState<GroupMemberRole>,
developerTools: Boolean,
connectionCode: String?,
connectionLoaded: Boolean,
groupRelay: GroupRelay? = null,
getContactChat: (Long) -> Chat?,
openDirectChat: (Long) -> Unit,
@@ -538,22 +545,23 @@ fun GroupMemberInfoLayout(
SectionDividerSpaced()
}
val memberConnected = member.memberActive || (groupInfo.useRelays && member.memberCurrent)
val showMemberSupportChat = !openedFromSupportChat &&
groupInfo.membership.memberRole >= GroupMemberRole.Moderator &&
member.memberRole != GroupMemberRole.Relay &&
((groupInfo.fullGroupPreferences.support.on && member.memberRole < GroupMemberRole.Moderator)
|| member.supportChat != null)
val canVerifyCode = connectionCode != null && member.memberRole != GroupMemberRole.Relay
// the same condition decides whether the code is requested, so the row is shown disabled until it arrives
val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded)
val canSyncConn = cStats != null && cStats.ratchetSyncAllowed
if ((member.memberActive || (groupInfo.useRelays && member.memberCurrent))
&& (showMemberSupportChat || canVerifyCode || canSyncConn)) {
if (memberConnected && (showMemberSupportChat || canVerifyCode || canSyncConn)) {
SectionView {
if (showMemberSupportChat) {
SupportChatButton()
}
if (canVerifyCode) {
VerifyCodeButton(member.verified, verifyClicked)
VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null)
}
if (canSyncConn) {
SynchronizeConnectionButton(syncMemberConnection)
@@ -655,6 +663,20 @@ fun GroupMemberInfoLayout(
SimplexServers(stringResource(MR.strings.sending_via), sndServers)
}
}
} else if (!connectionLoaded && memberConnected) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) {
// the status is set from the same receiving queues as the servers below, so it is reserved with them
SectionItemView {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(MR.strings.network_status))
Icon(painterResource(MR.images.ic_info), stringResource(MR.strings.network_status), tint = MaterialTheme.colors.secondary)
}
}
SwitchAddressButton(disabled = true, switchAddress = {})
SimplexServers(stringResource(MR.strings.receiving_via), emptyList())
SimplexServers(stringResource(MR.strings.sending_via), emptyList())
}
}
val connFailedErr = member.activeConn?.connFailedErr
@@ -1117,6 +1139,7 @@ fun PreviewGroupMemberInfoLayout() {
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,
connectionCode = "123",
connectionLoaded = true,
getContactChat = { Chat.sampleData },
openDirectChat = {},
createMemberContact = {},
@@ -76,6 +76,15 @@ fun MemberSupportChatAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { MemberSupportChatToolbarTitle(scopeMember_) },
onTitleClick = {
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(null)
val connectionLoaded = mutableStateOf(false)
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
GroupMemberInfoView(rhId, groupInfo, scopeMember_, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = true, close = closeCurrent) {
closeCurrent()
close()
}
}
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, scopeMember_.groupMemberId)
val stats = r?.second
@@ -85,14 +94,9 @@ fun MemberSupportChatAppBar(
} else {
null
}
ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
remember { derivedStateOf { chatModel.getGroupMember(scopeMember_.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = true, close = closeCurrent) {
closeCurrent()
close()
}
}
}
connStats.value = stats
connectionCode.value = code
connectionLoaded.value = true
}
},
onTop = !oneHandUI.value || !chatBottomBar.value,
@@ -114,13 +114,13 @@ fun CIImageView(
onClick = onClick
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, imageBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
}
@Composable
fun ImageView(painter: Painter, image: String, fileSource: CryptoFile?, onClick: () -> Unit) {
fun ImageView(painter: Painter, image: String, onClick: () -> Unit) {
// On my Android device Compose fails to display 6000x6000 px WebP image with exception:
// IllegalStateException: Recording currently in progress - missing #endRecording() call?
// but can display 5000px image. Using even lower value here just to feel safer.
@@ -138,7 +138,7 @@ fun CIImageView(
onClick = onClick
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
} else {
@@ -149,14 +149,10 @@ fun CIImageView(
onClick = {}
)
.onRightClick { showMenu.value = true }
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentAlignment = Alignment.Center
) {
imageView(previewBitmap, onClick = {
if (fileSource != null) {
openFile(fileSource)
}
})
imageView(previewBitmap, onClick = onClick)
Icon(
painterResource(MR.images.ic_open_in_new),
contentDescription = stringResource(MR.strings.image_descr),
@@ -191,29 +187,33 @@ fun CIImageView(
}
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
.desktopModifyBlurredState(blurred, showMenu),
contentAlignment = Alignment.TopEnd
) {
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
if (chatModel.connectedToRemote()) {
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
withBGApi {
// Hidden media is not worth reading, decoding at full size and caching.
val revealed = !blurHidesMedia(!smallView, blurred)
if (revealed) {
if (chatModel.connectedToRemote()) {
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
withBGApi {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = imageAndFilePath(file)
}
}
}
} else {
LaunchedEffect(file) {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = imageAndFilePath(file)
res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) }
}
}
}
} else {
LaunchedEffect(file) {
if (res.value == null || res.value!!.third != getLoadedFilePath(file)) {
res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) }
}
}
}
val loaded = res.value
val loaded = if (revealed) res.value else null
if (loaded != null && file != null) {
val (imageBitmap, data, _) = loaded
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) })
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, onClick) })
} else {
imageView(previewBitmap, onClick = {
if (file != null) {
@@ -54,7 +54,7 @@ fun CIVideoView(
}
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
.desktopModifyBlurredState(blurred, showMenu),
contentAlignment = Alignment.TopEnd
) {
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
@@ -84,11 +84,11 @@ fun CIVideoView(
val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) }
val decrypted = uriDecrypted.value
if (decrypted != null && smallView) {
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, sizeMultiplier, openFullscreen = openFullscreen)
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, blurred, sizeMultiplier, openFullscreen = openFullscreen)
} else if (decrypted != null) {
VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
} else if (smallView) {
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, sizeMultiplier, openFullscreen = openFullscreen)
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, blurred, sizeMultiplier, openFullscreen = openFullscreen)
} else {
VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
}
@@ -184,16 +184,17 @@ private fun SmallVideoViewEncrypted(
defaultPreview: ImageBitmap,
autoPlay: MutableState<Boolean>,
showMenu: MutableState<Boolean>,
blurred: MutableState<Boolean>,
sizeMultiplier: Float,
openFullscreen: () -> Unit,
) {
var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) }
val onLongClick = { showMenu.value = true }
Box {
VideoPreviewImageView(defaultPreview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick)
VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick)
if (decryptionInProgress) {
VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick)
} else if (!file.showStatusIconInSmallView) {
} else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
PlayButton(false, sizeMultiplier, onLongClick = onLongClick) {
decryptionInProgress = true
withBGApi {
@@ -216,6 +217,7 @@ private fun SmallVideoView(
defaultPreview: ImageBitmap,
defaultDuration: Long,
autoPlay: MutableState<Boolean>,
blurred: MutableState<Boolean>,
sizeMultiplier: Float,
openFullscreen: () -> Unit
) {
@@ -233,8 +235,8 @@ private fun SmallVideoView(
onLongClick = {},
{}
)
VideoPreviewImageView(preview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = openFullscreen, onLongClick = {})
if (!file.showStatusIconInSmallView) {
VideoPreviewImageView(preview, smallView = true, blurred = blurred, onClick = openFullscreen, onLongClick = {})
if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen)
}
}
@@ -421,7 +423,7 @@ fun VideoPreviewImageView(
onClick = onClick
)
.onRightClick(onLongClick)
.privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick),
.privacyBlur(!smallView, preview, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
)
}
@@ -153,6 +153,7 @@ fun FramedItemView(
@Composable
fun ciQuoteView(qi: CIQuote) {
val blurred = remember { mutableStateOf(appPreferences.privacyMediaBlurRadius.get() > 0) }
val sentColor = MaterialTheme.appColors.sentQuote
val receivedColor = MaterialTheme.appColors.receivedQuote
Row(
@@ -170,7 +171,7 @@ fun FramedItemView(
imageBitmap,
contentDescription = stringResource(MR.strings.image_descr),
contentScale = ContentScale.Crop,
modifier = Modifier.size(68.dp).clipToBounds()
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
)
}
is MsgContent.MCVideo -> {
@@ -182,7 +183,7 @@ fun FramedItemView(
imageBitmap,
contentDescription = stringResource(MR.strings.video_descr),
contentScale = ContentScale.Crop,
modifier = Modifier.size(68.dp).clipToBounds()
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
)
}
is MsgContent.MCFile, is MsgContent.MCVoice -> {
@@ -783,7 +783,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
} else {
withBGApi {
chatModel.appOpenUrlConnecting.value = true
planAndConnect(rhId, uri, close = null, cleanup = { chatModel.appOpenUrlConnecting.value = false })
planAndConnect(rhId, uri, close = { ModalManager.closeAllModalsEverywhere() }, cleanup = { chatModel.appOpenUrlConnecting.value = false })
}
}
}
@@ -311,26 +311,31 @@ fun ChatPreviewView(
mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} })
}
val uriHandler = LocalUriHandler.current
// Media in the chat list has no menu, so a menu opened from it must close at once, or the media stays revealed.
val noMenu = remember { mutableStateOf(false) }
LaunchedEffect(noMenu.value) { noMenu.value = false }
when (mc) {
is MsgContent.MCLink -> SmallContentPreview {
val image = remember(mc.preview.image) { base64ToBitmap(mc.preview.image) }
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
IconButton(
{ openBrowserAlert(mc.preview.uri, uriHandler) },
Modifier.desktopPointerHoverIconHand(),
) {
Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop)
Image(image, null, Modifier.desktopModifyBlurredState(blurred, noMenu).privacyBlur(fullSize = false, image, blurred, chatViewScrollState.collectAsState()), contentScale = ContentScale.Crop)
}
Box(Modifier.align(Alignment.TopEnd).size(15.sp.toDp()).background(Color.Black.copy(0.25f), CircleShape), contentAlignment = Alignment.Center) {
Icon(painterResource(MR.images.ic_arrow_outward), null, Modifier.size(13.sp.toDp()), tint = Color.White)
}
}
is MsgContent.MCImage -> SmallContentPreview {
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
CIImageView(image = mc.image, file = ci.file, provider, noMenu, smallView = true) {
val user = chatModel.currentUser.value ?: return@CIImageView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
}
is MsgContent.MCVideo -> SmallContentPreview {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, noMenu, smallView = true) {
val user = chatModel.currentUser.value ?: return@CIVideoView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
@@ -421,7 +426,7 @@ fun ChatPreviewView(
val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null
val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null
if (ci != null && showContentPreview) {
chatItemContentPreview(chat, ci)
key(ci.id) { chatItemContentPreview(chat, ci) }
}
if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) {
Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) {
@@ -44,6 +44,7 @@ fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) {
val useKeychain = remember { mutableStateOf(appPrefs.storeDBPassphrase.get()) }
val initialRandomDBPassphrase = remember { mutableStateOf(appPrefs.initialRandomDBPassphrase.get()) }
val storedKey = remember { val key = DatabaseUtils.ksDatabasePassword.get(); mutableStateOf(key != null && key != "") }
val keyStorage = remember(storedKey.value) { if (storedKey.value) DatabaseUtils.ksDatabasePassword.storage() else null }
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") }
val newKey = rememberSaveable { mutableStateOf("") }
@@ -60,6 +61,7 @@ fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) {
newKey,
confirmNewKey,
storedKey,
keyStorage,
initialRandomDBPassphrase,
progressIndicator,
migration,
@@ -105,6 +107,7 @@ fun DatabaseEncryptionLayout(
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
storedKey: MutableState<Boolean>,
keyStorage: String?,
initialRandomDBPassphrase: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>,
migration: Boolean,
@@ -196,7 +199,7 @@ fun DatabaseEncryptionLayout(
}
Column {
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase, migration)
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, keyStorage, initialRandomDBPassphrase, migration)
}
SectionBottomSpacer()
}
@@ -254,6 +257,7 @@ expect fun DatabaseEncryptionFooter(
useKeychain: MutableState<Boolean>,
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
keyStorage: String?,
initialRandomDBPassphrase: MutableState<Boolean>,
migration: Boolean,
)
@@ -434,6 +438,7 @@ suspend fun encryptDatabase(
m.controller.apiSaveAppSettings(AppSettings.current.prepareForExport())
}
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
if (error == null && currentKey.value != newKey.value) appPrefs.shouldDeleteDatabaseBackups.set(true)
appPrefs.encryptionStartedAt.set(null)
val sqliteError = ((error as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
when {
@@ -459,12 +464,13 @@ suspend fun encryptDatabase(
if (migration) {
appPreferences.storeDBPassphrase.set(useKeychain.value)
}
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
if (useKeychain.value) {
DatabaseUtils.ksDatabasePassword.set(new)
} else {
removePassphraseFromKeyChain(useKeychain, storedKey, migration)
}
// DatabaseEncryptionView reads key storage when storedKey changes, so storedKey is updated after the key is saved
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted))
}
@@ -539,6 +545,7 @@ fun PreviewDatabaseEncryptionLayout() {
newKey = remember { mutableStateOf("") },
confirmNewKey = remember { mutableStateOf("") },
storedKey = remember { mutableStateOf(true) },
keyStorage = stringResource(MR.strings.keystore_key_storage_strongbox),
initialRandomDBPassphrase = remember { mutableStateOf(true) },
progressIndicator = remember { mutableStateOf(false) },
migration = false,
@@ -556,12 +556,16 @@ suspend fun deleteChatAsync(m: ChatModel) {
fun deleteChatDatabaseFilesAndState() {
val chat = File(dataDir, chatDatabaseFileName)
val chatBak = File(dataDir, "$chatDatabaseFileName.bak")
val chatExported = File(dataDir, "$chatDatabaseFileName.exported")
val agent = File(dataDir, agentDatabaseFileName)
val agentBak = File(dataDir, "$agentDatabaseFileName.bak")
val agentExported = File(dataDir, "$agentDatabaseFileName.exported")
chat.delete()
chatBak.delete()
chatExported.delete()
agent.delete()
agentBak.delete()
agentExported.delete()
filesDir.deleteRecursively()
filesDir.mkdir()
remoteHostsDir.deleteRecursively()
@@ -594,6 +598,11 @@ fun deleteChatDatabaseFilesAndState() {
ntfManager.cancelAllNotifications()
}
fun deleteDatabaseBackups() {
File(dataDir, "$chatDatabaseFileName.bak").delete()
File(dataDir, "$agentDatabaseFileName.bak").delete()
}
private suspend fun exportArchive(
m: ChatModel,
progressIndicator: MutableState<Boolean>,
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.drawBarsBlurred
import chat.simplex.common.ui.theme.CurrentColors
fun Modifier.blurredBackgroundModifier(
@@ -32,7 +33,7 @@ fun Modifier.blurredBackgroundModifier(
return if (appPlatform.isAndroid) {
this.androidBlurredModifier(keyboardInset, blurRadius.value, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
} else {
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, handler, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
}
}
@@ -100,40 +101,44 @@ private fun Modifier.desktopBlurredModifier(
blurRadius: State<Int>,
keyboardCoversBar: Boolean,
onTop: Boolean,
handler: AppBarHandler,
graphicsLayer: GraphicsLayer,
backgroundGraphicsLayer: GraphicsLayer,
backgroundGraphicsLayerSize: State<IntSize>,
density: Density
): Modifier = this
.graphicsLayer {
renderEffect = if (blurRadius.value > 0) BlurEffect(blurRadius.value.dp.toPx(), blurRadius.value.dp.toPx()) else null
clip = blurRadius.value > 0
}
.drawBehind {
drawRect(CurrentColors.value.colors.background)
if (onTop) {
clipRect {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
val barWidth = size.width
val barHeight = size.height
// The blur is taken from a copy of the scrolled content rather than drawn from that layer, so unlike a layer's own
// filter it does not follow the content on its own: the bar is redrawn when the container reports the copy moved.
handler.contentVersion.value
drawBarsBlurred(blurRadius.value.dp.toPx(), barWidth, barHeight) {
drawRect(CurrentColors.value.colors.background, size = Size(barWidth, barHeight))
if (onTop) {
clipRect(0f, 0f, barWidth, barHeight) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
drawLayer(graphicsLayer)
}
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + size.height + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + barHeight + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
drawLayer(graphicsLayer)
}
}
}
@@ -78,6 +78,10 @@ class AppBarHandler(
val backgroundGraphicsLayerSize: MutableState<IntSize> = mutableStateOf(IntSize.Zero)
// Bars that blur a copy of the scrolled content depend on this to know the copy moved. It is bumped from a collector
// rather than while drawing: a write made during the draw phase invalidates the bars every frame and never settles.
val contentVersion: MutableState<Int> = mutableStateOf(0)
companion object {
var appBarMaxHeightPx: Int = 0
}
@@ -38,6 +38,8 @@ object DatabaseUtils {
passphrase.set(null)
initVector.set(null)
}
fun storage(): String? = cryptor.keyStorage(alias)
}
fun hasAtLeastOneDatabase(rootDir: String): Boolean =
@@ -194,7 +194,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel
@Composable
fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongClick: () -> Unit) {
val image = base64ToBitmap(linkPreview.image)
val image = remember(linkPreview.image) { base64ToBitmap(linkPreview.image) }
Column(
Modifier
.layoutId(CHAT_IMAGE_LAYOUT_ID)
@@ -206,8 +206,8 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
stringResource(MR.strings.image_descr_link_preview),
modifier = Modifier
.fillMaxWidth()
.desktopModifyBlurredState(true, blurred, showMenu)
.privacyBlur(true, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
.desktopModifyBlurredState(blurred, showMenu)
.privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = ContentScale.FillWidth,
)
Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) {
@@ -453,7 +453,7 @@ private fun MutableState<MigrationToState?>.PassphraseEnteringView(currentKey: S
}
}
) {}
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, remember { mutableStateOf(false) }, true)
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, null, remember { mutableStateOf(false) }, true)
}
if (verifyingPassphrase.value) {
ProgressView()
@@ -1033,7 +1033,7 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
return v != lastVersion
}
private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat"
private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat?utm_source=app"
private const val CROWDFUNDING_CONTACT_URI = "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im"
@@ -1197,7 +1197,7 @@ fun GetStakeView(showFirstImage: Boolean, inCenterOfWindow: Boolean = false, clo
ColumnWithScrollBar(Modifier.pinchZoom().padding(horizontal = DEFAULT_PADDING)) {
// in the center of the window the page is wide enough for the title to fit on one line
val title = "Get a stake in\nSimpleX Chat"
val title = "Invest in\nSimpleX Chat"
AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false)
// What's new already shows the image of the first slide, above the link that opens this page
if (showFirstImage) {
@@ -74,6 +74,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: (
val simplexTeamUri =
"simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im"
val simplexNewsUri =
"simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im"
@Composable
fun SettingsLayout(
stopped: Boolean,
@@ -161,6 +164,7 @@ fun HelpAndSupportView(
SectionView(stringResource(MR.strings.settings_section_title_contact)) {
if (!chatModel.desktopNoUserNoRemote) {
SettingsActionItem(painterResource(MR.images.ic_bigtop_updates), stringResource(MR.strings.follow_simplex_network_news), { uriHandler.openVerifiedSimplexUri(simplexNewsUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
}
SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
@@ -43,7 +43,7 @@
<string name="callstatus_accepted">قُبلت المكالمة</string>
<string name="allow_calls_only_if">اسمح بالمكالمات فقط إذا سمحت جهة اتصالك بذلك.</string>
<string name="allow_message_reactions_only_if">اسمح بردود الفعل على الرسائل فقط إذا سمحت جهة اتصالك بذلك.</string>
<string name="keychain_is_storing_securely">يتم استخدام Android Keystore لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل.</string>
<string name="keychain_is_storing_securely">يتم استخدام Android Keystore%s لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل.</string>
<string name="empty_chat_profile_is_created">يتم إنشاء ملف تعريف دردشة فارغ بالاسم المقدم، ويفتح التطبيق كالمعتاد.</string>
<string name="answer_call">أجب الاتصال</string>
<string name="chat_preferences_always">دائمًا</string>
@@ -1016,6 +1016,7 @@
<string name="markdown_in_messages">Markdown in messages</string>
<string name="chat_with_the_founder">Send questions and ideas</string>
<string name="send_us_an_email">Send us email</string>
<string name="follow_simplex_network_news">Follow SimpleX Network News</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Chat console</string>
<string name="message_servers">Message servers</string>
@@ -1415,6 +1416,8 @@
<string name="status_no_e2e_encryption">no e2e encryption</string>
<string name="status_contact_has_e2e_encryption">contact has e2e encryption</string>
<string name="status_contact_has_no_e2e_encryption">contact has no e2e encryption</string>
<string name="call_not_encrypted_title">Call is not encrypted</string>
<string name="call_not_encrypted_desc">%s accepted the call without end-to-end encryption.</string>
<string name="call_connection_peer_to_peer">peer-to-peer</string>
<string name="call_connection_via_relay">via relay</string>
<string name="icon_descr_hang_up">Hang up</string>
@@ -1642,6 +1645,9 @@
<!-- DatabaseEncryptionView.kt -->
<string name="save_passphrase_in_keychain">Save passphrase in Keystore</string>
<string name="save_passphrase_in_settings">Save passphrase in settings</string>
<string name="keystore_key_storage_strongbox">Secure element</string>
<string name="keystore_key_storage_tee">TEE</string>
<string name="keystore_key_storage_software">Software</string>
<string name="database_encrypted">Database encrypted!</string>
<string name="error_encrypting_database">Error encrypting database</string>
<string name="remove_passphrase_from_keychain">Remove passphrase from Keystore?</string>
@@ -1658,7 +1664,7 @@
<string name="set_passphrase">Set passphrase</string>
<string name="enter_correct_current_passphrase">Please enter correct current passphrase.</string>
<string name="database_is_not_encrypted">Your chat database is not encrypted - set passphrase to protect it.</string>
<string name="keychain_is_storing_securely">Android Keystore is used to securely store passphrase - it allows notification service to work.</string>
<string name="keychain_is_storing_securely">Android Keystore%s is used to securely store passphrase - it allows notification service to work.</string>
<string name="settings_is_storing_in_clear_text">The passphrase is stored in settings as plaintext.</string>
<string name="encrypted_with_random_passphrase">Database is encrypted using a random passphrase, you can change it.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Please note</b>: you will NOT be able to recover or change passphrase if you lose it.]]></string>
@@ -1784,6 +1790,7 @@
<string name="rcv_group_event_invited_via_your_group_link">invited via your group link</string>
<string name="rcv_group_event_member_created_contact">requested connection</string>
<string name="rcv_group_event_new_member_pending_review">New member wants to join the group.</string>
<string name="rcv_group_event_msg_bad_signature">message rejected: bad signature</string>
<string name="snd_group_event_changed_member_role">you changed role of %s to %s</string>
<string name="snd_group_event_changed_role_for_yourself">you changed role for yourself to %s</string>
<string name="snd_group_event_member_blocked">you blocked %s</string>
@@ -2679,8 +2686,8 @@
<string name="v7_0_invest_descr" translatable="false">Crowdfunding on Wefunder.</string>
<string name="v7_0_crowdfunding" translatable="false">Crowdfunding on Wefunder</string>
<string name="v7_0_invest_learn_more" translatable="false">Learn more on Wefunder</string>
<string name="invest_banner_title" translatable="false">Get a stake in SimpleX Chat!</string>
<string name="invest_banner_subtitle" translatable="false">Invest on Wefunder from $100</string>
<string name="invest_banner_title" translatable="false">Invest in SimpleX Chat!</string>
<string name="invest_banner_subtitle" translatable="false">Crowdfunding on Wefunder</string>
<string name="v7_0_simplex_names">SimpleX public names (BETA)</string>
<string name="v7_0_simplex_names_descr">Public names for your channel or business.</string>
<string name="v7_0_channels">Better channels 📢</string>
@@ -78,7 +78,7 @@
<string name="allow_your_contacts_to_send_voice_messages">Позволи на вашите контакти да изпращат гласови съобщения.</string>
<string name="all_your_contacts_will_remain_connected_update_sent">Всички ваши контакти ще останат свързани. Актуализацията на профила ще бъде изпратена до вашите контакти.</string>
<string name="notifications_mode_service">Винаги включен</string>
<string name="keychain_is_storing_securely">Android Keystore се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи.</string>
<string name="keychain_is_storing_securely">Android Keystore%s се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи.</string>
<string name="empty_chat_profile_is_created">Създаен беше празен профил за чат с предоставеното име и приложението се отвари както обикновено.</string>
<string name="notifications_mode_off_desc">Приложението може да получава известия само когато работи, няма да се стартира услуга във фонов режим</string>
<string name="settings_section_title_icon">Икона на приложението</string>
@@ -125,7 +125,7 @@
<string name="settings_section_title_icon">Icona aplicació</string>
<string name="privacy_media_blur_radius">Desenfocar els mitjans</string>
<string name="settings_section_title_calls">Trucades</string>
<string name="keychain_is_storing_securely">Android Keystore s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni.</string>
<string name="keychain_is_storing_securely">Android Keystore%s s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore s\'utilitzarà per emmagatzemar de manera segura la frase de contrasenya després de reiniciar l\'aplicació o canviar la frase de contrasenya; permetrà rebre notificacions.</string>
<string name="cannot_access_keychain">No es pot accedir a Keystore per desar la contrasenya de la base de dades</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Tingueu en compte</b>: NO podreu recuperar ni canviar la contrasenya si la perdeu.]]></string>
@@ -280,7 +280,7 @@
<string name="update_database_passphrase">Aktualizovat přístupovou frázi</string>
<string name="enter_correct_current_passphrase">Zadejte prosím správnou aktuální přístupovou frázi.</string>
<string name="database_is_not_encrypted">Databáze chatu není šifrována - nastavte přístupovou frázi pro její ochranu.</string>
<string name="keychain_is_storing_securely">K bezpečnému uložení přístupové fráze slouží úložiště klíčů Android - umožňuje fungování služby oznámení.</string>
<string name="keychain_is_storing_securely">K bezpečnému uložení přístupové fráze slouží úložiště klíčů Android%s - umožňuje fungování služby oznámení.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Upozornění</b>: pokud přístupovou frázi ztratíte, NEBUDE možné ji obnovit ani změnit.]]></string>
<string name="database_will_be_encrypted_and_passphrase_stored">Databáze bude zašifrována a přístupová fráze bude uložena v úložišti klíčů.</string>
<string name="store_passphrase_securely">Přístupovou frázi uložte bezpečně, v případě její ztráty ji NEBUDE možné změnit.</string>
@@ -147,7 +147,7 @@
<string name="network_smp_proxy_mode_always_description">Brug altid privat routing.</string>
<string name="always_use_relay">Brug altid relæ</string>
<string name="rcv_group_and_other_events">og %d andre begivenheder</string>
<string name="keychain_is_storing_securely">Android Keystore bruges til sikkert at opbevare adgangssæt - det giver Notification Service mulighed for at arbejde.</string>
<string name="keychain_is_storing_securely">Android Keystore%s bruges til sikkert at opbevare adgangssæt - det giver Notification Service mulighed for at arbejde.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore vil blive brugt til sikkert at gemme adgangssæt, når du genstarter appen eller skifter adgangssætning - det giver mulighed for at modtage meddelelser.</string>
<string name="empty_chat_profile_is_created">En tom chatprofil med det angivne navn oprettes, og appen åbnes som sædvanligt.</string>
<string name="connect__a_new_random_profile_will_be_shared">En ny tilfældig profil deles.</string>
@@ -583,7 +583,7 @@
<string name="update_database_passphrase">Datenbank-Passwort aktualisieren</string>
<string name="enter_correct_current_passphrase">Bitte geben Sie das korrekte, aktuelle Passwort ein.</string>
<string name="database_is_not_encrypted">Ihre Chat-Datenbank ist nicht verschlüsselt. Bitte legen Sie ein Passwort fest, um sie zu schützen.</string>
<string name="keychain_is_storing_securely">Der Android-Keystore wird verwendet, um das Passwort sicher zu speichern. Dies ermöglicht die ordentliche Funktion des Benachrichtigungsdienstes.</string>
<string name="keychain_is_storing_securely">Der Android-Keystore%s wird verwendet, um das Passwort sicher zu speichern. Dies ermöglicht die ordentliche Funktion des Benachrichtigungsdienstes.</string>
<string name="encrypted_with_random_passphrase">Die Datenbank wird mit einem zufälligen Passwort verschlüsselt, Sie können es ändern.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Bitte beachten Sie</b>: Sie können das Passwort NICHT wiederherstellen oder ändern, wenn Sie es vergessen haben oder verlieren.]]></string>
<string name="keychain_allows_to_receive_ntfs">Der Android-Keystore wird verwendet, um das Passwort sicher zu speichern, nachdem Sie die App neu gestartet oder das Passwort geändert haben – dies ermöglicht den Empfang von Benachrichtigungen.</string>
@@ -242,7 +242,7 @@
<string name="alert_title_msg_bad_hash">Κακό μήνυμα hash</string>
<string name="privacy_media_blur_radius">Θάμπωση των μέσων</string>
<string name="settings_section_title_chat_database">Βάση δεδομένων συνομιλίας</string>
<string name="keychain_is_storing_securely">Το Android Keystore χρησιμοποιείται για την ασφαλή αποθήκευση της φράσης πρόσβασης - επιτρέπει την υπηρεσία ειδοποιήσεων να λειτουργεί.</string>
<string name="keychain_is_storing_securely">Το Android Keystore%s χρησιμοποιείται για την ασφαλή αποθήκευση της φράσης πρόσβασης - επιτρέπει την υπηρεσία ειδοποιήσεων να λειτουργεί.</string>
<string name="member_info_member_blocked">αποκλεισμένος</string>
<string name="member_blocked_by_admin">Αποκλεισμένος από τον διαχειριστή</string>
<string name="cant_call_contact_alert_title">Δεν είναι δυνατή η κλήση επαφής</string>
@@ -51,7 +51,7 @@
<string name="users_delete_all_chats_deleted">Se eliminarán todos los chats y mensajes. ¡No puede deshacerse!</string>
<string name="accept_feature">Aceptar</string>
<string name="allow_to_send_disappearing">Se permiten mensajes temporales.</string>
<string name="keychain_is_storing_securely">Android Keystore se usará para almacenar la frase de contraseña de forma segura - permite que el servicio de notificaciones funcione.</string>
<string name="keychain_is_storing_securely">Android Keystore%s se usará para almacenar la frase de contraseña de forma segura - permite que el servicio de notificaciones funcione.</string>
<string name="users_add">Añadir perfil</string>
<string name="color_primary">Color</string>
<string name="allow_your_contacts_irreversibly_delete">Permites que tus contactos eliminan irreversiblemente los mensajes enviados. (24 horas)</string>
@@ -994,7 +994,7 @@
<string name="encrypt_database">رمزنگاری</string>
<string name="update_database_passphrase">به‌روزرسانی عبارت عبور پایگاه داده</string>
<string name="set_passphrase">تعیین عبارت عبور</string>
<string name="keychain_is_storing_securely">از مخزن کلید اندروید برای ذخیره امن عبارت عبور استفاده می‌شود - به سرویس اعلان اجازه عمل می‌دهد.</string>
<string name="keychain_is_storing_securely">از مخزن کلید اندروید%s برای ذخیره امن عبارت عبور استفاده می‌شود - به سرویس اعلان اجازه عمل می‌دهد.</string>
<string name="you_have_to_enter_passphrase_every_time">باید هر بار که برنامه شروع می‌شود عبارت عبور را وارد کنید - در دستگاه ذخیره نمی‌شود.</string>
<string name="encrypted_database">پایگاه داده رمزنگاری شده</string>
<string name="icon_descr_contact_checked">مخاطب بررسی شد</string>
@@ -115,7 +115,7 @@
<string name="delete_files_and_media_question">Poistetaanko tiedostot ja media\?</string>
<string name="total_files_count_and_size">%d tiedosto(a), joiden kokonaiskoko on %s</string>
<string name="current_passphrase">Nykyinen tunnuslause…</string>
<string name="keychain_is_storing_securely">Android Keystorea käytetään salalauseen turvalliseen tallentamiseen - se mahdollistaa ilmoituspalvelun toiminnan.</string>
<string name="keychain_is_storing_securely">Android Keystorea%s käytetään salalauseen turvalliseen tallentamiseen - se mahdollistaa ilmoituspalvelun toiminnan.</string>
<string name="encrypted_with_random_passphrase">Tietokanta on salattu satunnaisella tunnuslauseella, voit muuttaa sitä.</string>
<string name="database_error">Tietokantavirhe</string>
<string name="database_passphrase_is_required">Keskustelun avaamiseen tarvitaan tietokannan tunnuslause.</string>
@@ -584,7 +584,7 @@
<string name="new_passphrase">Nouvelle phrase secrète…</string>
<string name="confirm_new_passphrase">Confirmer la nouvelle phrase secrète…</string>
<string name="update_database_passphrase">Mise à jour de la phrase secrète de la base de données</string>
<string name="keychain_is_storing_securely">Le Keystore d\'Android est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner.</string>
<string name="keychain_is_storing_securely">Le Keystore d\'Android%s est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner.</string>
<string name="you_have_to_enter_passphrase_every_time">Vous devez saisir la phrase secrète à chaque fois que l\'application démarre - elle n\'est pas stockée sur l\'appareil.</string>
<string name="encrypt_database_question">Chiffrer la base de données \?</string>
<string name="change_database_passphrase_question">Changer la phrase secrète de la base de données \?</string>
@@ -41,7 +41,7 @@
<string name="icon_descr_cancel_link_preview">hivatkozáselőnézet visszavonása</string>
<string name="network_session_mode_user_description"><![CDATA[<b>Az összes csevegési profiljához az alkalmazásban</b> külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.]]></string>
<string name="both_you_and_your_contact_can_send_disappearing">Mindkét fél küldhet eltűnő üzeneteket.</string>
<string name="keychain_is_storing_securely">Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését.</string>
<string name="keychain_is_storing_securely">Az Android Keystore-t%s a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését.</string>
<string name="alert_title_msg_bad_hash">Hibás az üzenet kivonata</string>
<string name="color_background">Háttér</string>
<string name="socks_proxy_setting_limitations"><![CDATA[<b>Megjegyzés</b>: az üzenet- és a fájlátjátszók SOCKS proxyn keresztül kapcsolódnak. A hívások pedig közvetlen kapcsolatot használnak.]]></string>
@@ -987,7 +987,7 @@
<string name="settings_section_title_themes">Tema</string>
<string name="settings_section_title_delivery_receipts">Kirim tanda terima kiriman ke</string>
<string name="alert_text_fragment_encryption_out_of_sync_old_database">Hal ini dapat terjadi ketika Anda atau koneksi Anda menggunakan cadangan basis data lama.</string>
<string name="keychain_is_storing_securely">Android Keystore digunakan untuk menyimpan frasa sandi dengan aman - memungkinkan layanan notifikasi berfungsi.</string>
<string name="keychain_is_storing_securely">Android Keystore%s digunakan untuk menyimpan frasa sandi dengan aman - memungkinkan layanan notifikasi berfungsi.</string>
<string name="remove_passphrase">Hapus</string>
<string name="encrypt_database">Enkripsi</string>
<string name="messages_section_title">Pesan</string>
@@ -256,7 +256,7 @@
<string name="error_importing_database">Errore nell\'importazione del database della chat</string>
<string name="group_full_name_field">Nome completo del gruppo:</string>
<string name="full_backup">Backup dei dati dell\'app</string>
<string name="keychain_is_storing_securely">L\'archivio chiavi di Android è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica.</string>
<string name="keychain_is_storing_securely">L\'archivio chiavi di Android%s è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica.</string>
<string name="allow_your_contacts_to_send_voice_messages">Permetti ai tuoi contatti di inviare messaggi vocali.</string>
<string name="chat_database_deleted">Database della chat eliminato</string>
<string name="settings_section_title_icon">Icona app</string>
@@ -51,7 +51,7 @@
<string name="allow_voice_messages_question">לאפשר הודעות קוליות\?</string>
<string name="allow_your_contacts_to_send_voice_messages">אפשר לאנשי קשר לשלוח הודעות קוליות.</string>
<string name="notifications_mode_service">תמיד פעיל</string>
<string name="keychain_is_storing_securely">ישנו שימוש ב־Android Keystore כדי לאחסן בבטחה את הסיסמה – דבר המאפשר לשירות ההתראות לעבוד.</string>
<string name="keychain_is_storing_securely">ישנו שימוש ב־Android Keystore%s כדי לאחסן בבטחה את הסיסמה – דבר המאפשר לשירות ההתראות לעבוד.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore יאחסן בבטחה את הסיסמה לאחר הפעלה מחדש של האפליקציה או שינוי הסיסמה – דבר המאפשר קבלת התראות.</string>
<string name="full_backup">גיבוי נתוני האפליקציה</string>
<string name="settings_section_title_icon">סמל האפליקציה</string>
@@ -61,7 +61,7 @@
<string name="allow_direct_messages">メンバーへのダイレクトメッセージを許可</string>
<string name="allow_to_send_voice">音声メッセージの送信を許可</string>
<string name="notifications_mode_off_desc">アクティブの時のみに通知が出ます。バックグラウンド通知サービスは起動されません。</string>
<string name="keychain_is_storing_securely">Androidキーストアはパスフレーズの保管に使われます。通知機能に必要です。</string>
<string name="keychain_is_storing_securely">Androidキーストア%sはパスフレーズの保管に使われます。通知機能に必要です。</string>
<string name="keychain_allows_to_receive_ntfs">再起動時とパスフレーズ変更時にAndroidキーストアがパスフレーズの保管に使われます。通知機能に必要です。</string>
<string name="answer_call">通話に応答</string>
<string name="settings_section_title_icon">アプリのアイコン</string>
@@ -148,7 +148,7 @@
<string name="allow_irreversible_message_deletion_only_if">대화 상대가 허용하는 경우에만 영구적인 메시지 삭제를 허용합니다. (24 시간)</string>
<string name="all_your_contacts_will_remain_connected">모든 대화 상대가 연결된 상태로 유지됩니다.</string>
<string name="notifications_mode_service">항상 켜기</string>
<string name="keychain_is_storing_securely">Android 암호 저장소는 암호를 안전하게 저장하는 데 사용됩니다 - 알림 서비스가 작동할 수 있습니다.</string>
<string name="keychain_is_storing_securely">Android 암호 저장소%s는 암호를 안전하게 저장하는 데 사용됩니다 - 알림 서비스가 작동할 수 있습니다.</string>
<string name="keychain_allows_to_receive_ntfs">앱을 다시 시작하거나 암호를 변경한 후 Android 암호 저장소를 사용하여 암호를 안전하게 저장합니다. - 알림을 받을 수 있습니다.</string>
<string name="notifications_mode_off_desc">앱이 실행 중일 때만 알림을 받을 수 있으며, 백그라운드 서비스는 시작되지 않습니다.</string>
<string name="full_backup">앱 데이터 백업</string>
@@ -523,7 +523,7 @@
<string name="all_app_data_will_be_cleared">Visi programėlės duomenys bus ištrinti.</string>
<string name="empty_chat_profile_is_created">Sukuriamas tuščias pokalbių profilis nurodytu pavadinimu ir programėlė atveriama kaip įprasta.</string>
<string name="settings_section_title_app">Programėlė</string>
<string name="keychain_is_storing_securely">Saugiam slaptafrazės saugojimui yra naudojama „Android Keystore“ – tai įgalina pranešimų tarnybą veikti.</string>
<string name="keychain_is_storing_securely">Saugiam slaptafrazės saugojimui yra naudojama „Android Keystore“%s – tai įgalina pranešimų tarnybą veikti.</string>
<string name="color_secondary_variant">Papildoma antrinė spalva</string>
<string name="color_primary_variant">Papildomas akcentavimas</string>
<string name="allow_to_send_files">Leisti siųsti failus ir mediją.</string>
@@ -1672,7 +1672,7 @@
<string name="set_passphrase">Iestatīt frāzi</string>
<string name="enter_correct_current_passphrase">Ievadiet pareizo pašreizējo frāzi</string>
<string name="database_is_not_encrypted">Datubāze nav šifrēta</string>
<string name="keychain_is_storing_securely">Atslēgu glabātuve tiek droši glabāta</string>
<string name="keychain_is_storing_securely">Atslēgu glabātuve%s tiek droši glabāta</string>
<string name="settings_is_storing_in_clear_text">Iestatījumi tiek glabāti parastā tekstā</string>
<string name="encrypted_with_random_passphrase">Šifrēts ar nejaušu frāzi</string>
<string name="impossible_to_recover_passphrase"><![CDATA[Nav iespējams atgūt frāzi]]></string>
@@ -117,7 +117,7 @@
<string name="network_smp_proxy_mode_always_description">Bruk alltid privat ruting.</string>
<string name="always_use_relay">Bruk alltid relé</string>
<string name="rcv_group_and_other_events">og %d andre hendelser</string>
<string name="keychain_is_storing_securely">Android Keystore brukes til å lagre passord på en sikker måte – det gjør at varslingstjenesten fungerer.</string>
<string name="keychain_is_storing_securely">Android Keystore%s brukes til å lagre passord på en sikker måte – det gjør at varslingstjenesten fungerer.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore brukes til å trygt lagre passordet ditt etter at du restarter appen eller bytter passord - det gjør at du kan motta varsler.</string>
<string name="empty_chat_profile_is_created">En tom chat-profil med navnet du har valgt vil bli laget, og appen åpnes som vanlig.</string>
<string name="connect__a_new_random_profile_will_be_shared">En ny tilfeldig profil vil bli delt.</string>
@@ -79,7 +79,7 @@
<string name="icon_descr_audio_off">Geluid uit</string>
<string name="full_backup">Back-up van app gegevens</string>
<string name="answer_call">Beantwoord oproep</string>
<string name="keychain_is_storing_securely">Android Keychain wordt gebruikt om het wachtwoord veilig op te slaan, hierdoor kan de meldings service werken.</string>
<string name="keychain_is_storing_securely">Android Keychain%s wordt gebruikt om het wachtwoord veilig op te slaan, hierdoor kan de meldings service werken.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keychain wordt gebruikt om het wachtwoord veilig op te slaan nadat u de app opnieuw hebt opgestart of het wachtwoord heeft gewijzigd, hiermee kunt u meldingen ontvangen.</string>
<string name="app_version_code">App build: %s</string>
<string name="notifications_mode_off_desc">App kan alleen meldingen ontvangen wanneer deze actief is, er wordt geen achtergrondservice gestart</string>
@@ -538,7 +538,7 @@
<string name="update_database">Aktualizuj</string>
<string name="update_database_passphrase">Aktualizuj hasło do bazy danych</string>
<string name="database_is_not_encrypted">Twoja baza danych czatu nie jest szyfrowana - ustaw hasło, aby ją chronić.</string>
<string name="keychain_is_storing_securely">Android Keystore służy do bezpiecznego przechowywania hasła - umożliwia działanie usługi powiadomień.</string>
<string name="keychain_is_storing_securely">Android Keystore%s służy do bezpiecznego przechowywania hasła - umożliwia działanie usługi powiadomień.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore będzie używany do bezpiecznego przechowywania hasła po ponownym uruchomieniu aplikacji lub zmianie hasła - pozwoli to na otrzymywanie powiadomień.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b> Uwaga</b>: NIE będziesz w stanie odzyskać ani zmienić hasła, jeśli je zgubisz.]]></string>
<string name="cannot_access_keychain">Nie można uzyskać dostępu do Keystore w celu zapisania hasła bazy danych</string>
@@ -54,7 +54,7 @@
<string name="call_on_lock_screen">Chamadas na tela de bloqueio:</string>
<string name="icon_descr_audio_on">Áudio ligado</string>
<string name="chat_database_imported">Banco de dados do chat importado</string>
<string name="keychain_is_storing_securely">O Android Keystore é usado para armazenar a senha com segurança. Isso permite que o serviço de notificações funcione.</string>
<string name="keychain_is_storing_securely">O Android Keystore%s é usado para armazenar a senha com segurança. Isso permite que o serviço de notificações funcione.</string>
<string name="keychain_allows_to_receive_ntfs">O Android Keystore será usado para armazenar a senha com segurança após você reiniciar o aplicativo ou alterar a senha, permitindo continuar recebendo notificações.</string>
<string name="cannot_access_keychain">Não é possível acessar a Keystore para salvar a senha do banco de dados</string>
<string name="chat_is_stopped_indication">O chat está parado</string>
@@ -166,7 +166,7 @@
<string name="voice_messages_prohibited">Mensagens de voz proibidas!</string>
<string name="voice_message_with_duration">Mensagem de voz (%1$s)</string>
<string name="app_version_name">Versão da aplicação: v%s</string>
<string name="keychain_is_storing_securely">O Android Keystore é usado para armazenar com segurança a senha - permite que o serviço de notificações funcione.</string>
<string name="keychain_is_storing_securely">O Android Keystore%s é usado para armazenar com segurança a senha - permite que o serviço de notificações funcione.</string>
<string name="keychain_allows_to_receive_ntfs">O Android Keystore será usado para armazenar com segurança a senha depois de voçê reiniciar a aplicação ou alterar a senha - irá permitir receber notificações.</string>
<string name="notifications_will_be_hidden">As notificações serão entregues apenas até à aplicação parar!</string>
<string name="app_version_code">Compilação da aplicação: %s</string>
@@ -93,7 +93,7 @@
<string name="connect_plan_already_joining_the_group">Se alătură deja grupului!</string>
<string name="notifications_mode_service">Mereu pornit</string>
<string name="connect__a_new_random_profile_will_be_shared">Un nou profil aleatoriu va fi distribuit.</string>
<string name="keychain_is_storing_securely">Android Keystore este folosit pentru a stoca în siguranță parola. Acest lucru permite funcționarea serviciului de notificări.</string>
<string name="keychain_is_storing_securely">Android Keystore%s este folosit pentru a stoca în siguranță parola. Acest lucru permite funcționarea serviciului de notificări.</string>
<string name="rcv_group_and_other_events">și %d alte evenimente</string>
<string name="answer_call">Răspunde la apel</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore va fi folosit pentru a stoca în siguranță parola după ce repornești aplicația sau schimbi parola — acest lucru va permite primirea de notificări.</string>
@@ -585,7 +585,7 @@
<string name="update_database_passphrase">Поменять пароль</string>
<string name="enter_correct_current_passphrase">Пожалуйста, введите правильный пароль.</string>
<string name="database_is_not_encrypted">База данных НЕ зашифрована. Установите пароль, чтобы защитить Ваши данные.</string>
<string name="keychain_is_storing_securely">Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме.</string>
<string name="keychain_is_storing_securely">Android Keystore%s используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме.</string>
<string name="encrypted_with_random_passphrase">База данных зашифрована случайным паролем, Вы можете его поменять.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Внимание</b>: Вы не сможете восстановить или поменять пароль, если потеряете его.]]></string>
<string name="keychain_allows_to_receive_ntfs">Пароль базы данных будет безопасно сохранён в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления.</string>
@@ -651,7 +651,7 @@
<string name="set_password_to_export">Nastavte prístupovú frázu pre export</string>
<string name="to_reveal_profile_enter_password">Aby ste odhalili svoj skrytý profil, zadajte celé heslo do vyhľadávacieho poľa na stránke profilov chatov.</string>
<string name="network_proxy_auth_mode_username_password">Vaše prihlasovacie údaje môžu byť zaslané nešifrované.</string>
<string name="keychain_is_storing_securely">Android Keystore je použitý na bezpečné uloženie prístupovej frázy - umožňuje to fungovanie služby oznámení.</string>
<string name="keychain_is_storing_securely">Android Keystore%s je použitý na bezpečné uloženie prístupovej frázy - umožňuje to fungovanie služby oznámení.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore bude použitý na bezpečné uloženie prístupovej frázy po reštarte aplikácie alebo zmene prístupovej frázy - umožní to fungovanie služby oznámení.</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>Upozornenie</b>: ak stratíte vašu prístupovú frázu, NEBUDE možné ju obnoviť ani zmeniť.]]></string>
<string name="change_database_passphrase_question">Zmeniť prístupovú frázu k databáze?</string>
@@ -23,7 +23,7 @@
<string name="color_primary_variant">เน้นสีเพิ่มเติม</string>
<string name="settings_section_title_icon">ไอคอนแอป</string>
<string name="v5_0_app_passcode">รหัสผ่านแอป</string>
<string name="keychain_is_storing_securely">Android Keystore ใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัย - ซึ่งจะช่วยให้บริการแจ้งเตือนทำงานได้</string>
<string name="keychain_is_storing_securely">Android Keystore%s ใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัย - ซึ่งจะช่วยให้บริการแจ้งเตือนทำงานได้</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore จะถูกใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัยหลังจากที่คุณรีสตาร์ทแอปหรือเปลี่ยนรหัสผ่าน - ซึ่งจะอนุญาตให้รับบริการแจ้งเตือนได้</string>
<string name="app_version_code">รุ่นแอป: %s</string>
<string name="full_backup">การสํารองข้อมูลแอป</string>
@@ -665,7 +665,7 @@
<string name="v4_2_auto_accept_contact_requests">Bağlanma isteklerini otomatik kabul et</string>
<string name="database_downgrade_warning">Uyarı: Bazı verileri kaybedebilirsin!</string>
<string name="all_your_contacts_will_remain_connected_update_sent">Tüm kişileriniz bağlı kalacaktır. Profil güncellemesi kişilerinize gönderilecektir.</string>
<string name="keychain_is_storing_securely">Android Keystore parolayı güvenli bir şekilde saklamak için kullanılır - bildirim hizmetinin çalışmasını sağlar.</string>
<string name="keychain_is_storing_securely">Android Keystore%s parolayı güvenli bir şekilde saklamak için kullanılır - bildirim hizmetinin çalışmasını sağlar.</string>
<string name="button_welcome_message">Karşılama mesajı</string>
<string name="group_welcome_title">Karşılama mesajı</string>
<string name="voice_messages_are_prohibited">Sesli mesajlar yasaktır.</string>
@@ -25,7 +25,7 @@
<string name="app_passcode_replaced_with_self_destruct">Пароль застосунку замінено паролем самознищення.</string>
<string name="full_backup">Резервне копіювання даних застосунку</string>
<string name="smp_servers_add_to_another_device">Додати на інший пристрій</string>
<string name="keychain_is_storing_securely">Сховище ключів Android використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати.</string>
<string name="keychain_is_storing_securely">Сховище ключів Android%s використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати.</string>
<string name="v4_2_group_links_desc">Адміністратори можуть створювати посилання для приєднання до групи.</string>
<string name="app_version_code">Збірка додатку: %s</string>
<string name="allow_voice_messages_only_if">Дозволити голосові повідомлення тільки за умови, що ваш контакт дозволяє їх.</string>
@@ -82,7 +82,7 @@
<string name="connect_plan_already_joining_the_group">Đã tham gia nhóm rồi!</string>
<string name="always_use_relay">Luôn sử dụng relay</string>
<string name="rcv_group_and_other_events">và %d sự kiện khác</string>
<string name="keychain_is_storing_securely">Android Keystore được sử dụng để lưu trữ passphrase - nó cho phép dịch vụ thông báo hoạt động.</string>
<string name="keychain_is_storing_securely">Android Keystore%s được sử dụng để lưu trữ passphrase - nó cho phép dịch vụ thông báo hoạt động.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore sẽ được sử dụng để lưu trữ passphrase một cách an toàn sau khi bạn khởi động lại ứng dụng hoặc thay đổi passphrase - nó cho phép tiếp nhận thông báo.</string>
<string name="migrate_from_device_all_data_will_be_uploaded">Tất cả các liên hệ, cuộc hội thoại và tệp của bạn sẽ được mã hóa an toàn và tải lên từng phần tới các XFTP relay được chỉ định.</string>
<string name="v5_6_safer_groups_descr">Quản trị viên có thể chặn một thành viên khỏi tất cả.</string>
@@ -89,7 +89,7 @@
<string name="users_delete_profile_for">为此删除聊天资料</string>
<string name="delete_database">删除数据库</string>
<string name="keychain_allows_to_receive_ntfs">在你重启应用程序或者更换密码后安卓密钥库系统用来安全地保存密码——来确保收到通知。</string>
<string name="keychain_is_storing_securely">安卓密钥库系统用来安全地保存密码——来确保通知服务运作。</string>
<string name="keychain_is_storing_securely">安卓密钥库系统%s用来安全地保存密码——来确保通知服务运作。</string>
<string name="appearance_settings">外观</string>
<string name="app_version_title">应用程序版本</string>
<string name="full_backup">应用程序数据备份</string>
@@ -94,7 +94,7 @@
<string name="full_backup">備份應用程式資料</string>
<string name="settings_section_title_icon">應用程式圖示</string>
<string name="chat_database_imported">已匯入對話資料庫</string>
<string name="keychain_is_storing_securely">Android 金鑰庫是用於安全地儲存密碼 - 確保通知推送服務的運作。</string>
<string name="keychain_is_storing_securely">Android 金鑰庫%s是用於安全地儲存密碼 - 確保通知推送服務的運作。</string>
<string name="impossible_to_recover_passphrase"><![CDATA[<b>請注意</b>:如果你忘記了密碼你將不能再次復原或修改密碼。]]></string>
<string name="keychain_allows_to_receive_ntfs">當你重新啟動應用程式或修改密碼後, Android 金鑰庫將用來安全地儲存密碼 - 將允許接收訊息通知。</string>
<string name="chat_is_stopped_indication">聊天已停止</string>
@@ -182,9 +182,10 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
}
}
var windowFocused by remember { simplexWindowState.windowFocused }
LaunchedEffect(windowFocused) {
val showCallView = ChatModel.showCallView.value
LaunchedEffect(windowFocused, showCallView) {
val delay = ChatController.appPrefs.laLockDelay.get()
if (!windowFocused && ChatModel.showAuthScreen.value && delay > 0) {
if (!windowFocused && !showCallView && ChatModel.showAuthScreen.value && delay > 0) {
delay(delay * 1000L)
// Trigger auth state check when delay ends (and if it ends)
AppLock.recheckAuthState()
@@ -76,12 +76,11 @@ internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVid
override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat {
// libvlc passes the size the decoder padded the picture to, not the size of the picture (dav1d
// pads to a multiple of 128, so 1920x1080 arrives as 1920x1152), and vlc stretches the picture to
// fill whatever size is returned. Ask for the size of the track being played instead. The format
// is negotiated more than once, and vlc has not selected the track yet on the first calls
val player = mediaPlayer
val tracks = player?.media()?.info()?.videoTracks()
val playingTrack = player?.video()?.track()
val track = tracks?.firstOrNull { it.id() == playingTrack } ?: tracks?.singleOrNull()
// fill whatever size is returned. Ask for the size of the video track instead. Not the selected
// track: video().track() takes the input lock, which stop() holds while it waits for this thread
// to finish, so asking here deadlocks both, and every caller of the player after them
val tracks = mediaPlayer?.media()?.info()?.videoTracks()
val track = tracks?.singleOrNull()
// Both track sides or neither: one side from the track and the other from the padded size libvlc
// passed never described the same picture, and transposing such a pair compounds the mismatch
val trackW = track?.width() ?: 0
@@ -0,0 +1,95 @@
package chat.simplex.common.platform
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.asComposeCanvas
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.Density
import org.jetbrains.skia.*
import kotlin.math.ceil
// Skia's raster blur costs width x (bar + 6 sigma) pixels every frame, which is what makes the bars lag. The blur cannot be
// made cheaper by scaling the layer it runs on, because a layer's filter is evaluated in device space: any scale applied to
// the layer scales the sigma straight back up, which weakens the blur without saving anything. So the bar is drawn into a
// narrower offscreen surface and blurred there, at its own resolution, and only the finished blur is scaled back out.
// Only the width is reduced: a bar is just AppBarHeight tall, and taking rows away from it visibly weakens the blur.
private const val MAX_BLUR_NARROWING = 8
private const val MIN_BLUR_WIDTH = 128
// How small the blur's own sigma is allowed to get. Below about this the narrowed copy is barely blurred, and stretching it
// back out shows the steps of the narrowing instead of a blur.
private const val MIN_NARROWED_SIGMA = 1.5f
// The rows are halved as well, with the vertical sigma halved to match, which is only sound because the copy is drawn at
// that size rather than scaled afterwards. A bar has few rows to begin with, so it is not reduced past this.
private const val MIN_BLUR_HEIGHT = 24
// The panes of the desktop window have different widths, so bars of several sizes are drawn in the same frame and a single
// pair of surfaces would be reallocated for each of them. Keeping one pair per size, with the least recently drawn size
// released once there are more than the window can show at once, allocates each pair once instead.
private const val MAX_BAR_SURFACES = 4
private class BarBlurSurface(width: Int, height: Int) {
val surface: Surface = Surface.makeRaster(ImageInfo.makeN32Premul(width, height))
fun close() = surface.close()
}
private val barBlurSurfaces = ThreadLocal.withInitial { LinkedHashMap<Long, BarBlurSurface>() }
private fun surfaceFor(width: Int, height: Int): BarBlurSurface {
val cached = barBlurSurfaces.get()
val key = (width.toLong() shl 32) or height.toLong()
val surface = cached.remove(key) ?: BarBlurSurface(width, height)
cached[key] = surface
while (cached.size > MAX_BAR_SURFACES) {
cached.remove(cached.keys.first())?.close()
}
return surface
}
private fun narrowing(sigma: Float, barWidth: Float): Int {
var scale = 1
while (sigma / (scale * 2) >= MIN_NARROWED_SIGMA && scale < MAX_BLUR_NARROWING && barWidth / (scale * 2) >= MIN_BLUR_WIDTH) scale *= 2
return scale
}
private fun shortening(sigma: Float, barHeight: Float): Int =
if (sigma / 2 >= MIN_NARROWED_SIGMA && barHeight / 2 >= MIN_BLUR_HEIGHT) 2 else 1
actual fun DrawScope.drawBarsBlurred(radiusPx: Float, barWidth: Float, barHeight: Float, drawBar: DrawScope.() -> Unit) {
val sigma = BlurEffect.convertRadiusToSigma(radiusPx)
val scale = narrowing(sigma, barWidth)
val rows = shortening(sigma, barHeight)
val width = ceil(barWidth / scale).toInt()
val height = ceil(barHeight / rows).toInt()
if (width <= 0 || height <= 0) return
val surface = surfaceFor(width, height).surface
surface.canvas.clear(Color.TRANSPARENT)
// the blur is the layer's own paint, so it runs at this surface's resolution rather than at the canvas the bar draws to
surface.canvas.saveLayer(Rect.makeWH(width.toFloat(), height.toFloat()), Paint().apply {
imageFilter = ImageFilter.makeBlur(sigma / scale, sigma / rows, FilterTileMode.CLAMP)
})
CanvasDrawScope().draw(Density(density, fontScale), layoutDirection, surface.canvas.asComposeCanvas(), Size(width.toFloat(), height.toFloat())) {
scale(1f / scale, 1f / rows, Offset.Zero) { drawBar() }
}
surface.canvas.restore()
// The surface is whole pixels while the bar is not, so only the bar's own area is taken from it.
drawIntoCanvas {
it.nativeCanvas.drawImageRect(
surface.makeImageSnapshot(),
Rect.makeWH(barWidth / scale, barHeight / rows),
Rect.makeWH(barWidth, barHeight),
SamplingMode.LINEAR,
null,
true
)
}
}
@@ -12,4 +12,6 @@ actual val cryptor: CryptorInterface = object : CryptorInterface {
override fun deleteKey(alias: String) {
// LALAL
}
override fun keyStorage(alias: String): String? = null
}
@@ -64,6 +64,9 @@ actual fun LazyColumnWithScrollBar(
}
val state = state ?: handler.listState
val connection = handler.connection
LaunchedEffect(state, handler) {
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }.collect { handler.contentVersion.value++ }
}
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state
// (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row)
val scrollBarDraggingState = remember { mutableStateOf(false) }
@@ -202,6 +205,9 @@ actual fun ColumnWithScrollBar(
}
}
val state = state ?: handler.scrollState
LaunchedEffect(state, handler) {
snapshotFlow { state.value }.collect { handler.contentVersion.value++ }
}
val connection = handler.connection
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on column state
// (exact scroll position is available but in Int, not Float)
@@ -20,6 +20,7 @@ import java.io.IOException
import java.net.BindException
import java.security.SecureRandom
import java.util.Base64
import java.util.concurrent.atomic.AtomicBoolean
private const val SERVER_HOST = "localhost"
private const val SERVER_PORT = 50395
@@ -31,7 +32,7 @@ val connections = ArrayList<WebSocket>()
actual fun ActiveCallView() {
val scope = rememberCoroutineScope()
WebRTCController(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCController: $apiMsg")
Log.d(TAG, "received from WebRTCController: ${apiMsg.resp.javaClass.simpleName}")
val call = chatModel.activeCall.value
if (call != null) {
Log.d(TAG, "has active call $call")
@@ -202,7 +203,7 @@ fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (
}
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCController LaunchedEffect executing $cmd")
Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(cmd)
}
@@ -232,10 +233,16 @@ fun startServer(
val resourceNotFound = newFixedLengthResponse(Status.NOT_FOUND, "text/plain", "This page couldn't be found")
val webSocketAccepted = AtomicBoolean(false)
override fun handle(session: IHTTPSession): Response {
return when {
session.headers["upgrade"] == "websocket" ->
if (hasValidCallServerToken(session.parameters, token)) {
if (
session.headers["origin"] == "http://${SERVER_HOST}:${listeningPort}"
&& hasValidCallServerToken(session.parameters, token)
&& webSocketAccepted.compareAndSet(false, true)
) {
super.handle(session)
} else {
unauthorizedResponse()
@@ -289,7 +296,7 @@ class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTT
// onResponse(message.textPayload)
onResponse(json.decodeFromString(message.textPayload))
} catch (e: Exception) {
Log.e(TAG, "failed parsing browser message: $message")
Log.e(TAG, "failed parsing browser message")
}
}
@@ -59,6 +59,7 @@ actual fun DatabaseEncryptionFooter(
useKeychain: MutableState<Boolean>,
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
keyStorage: String?,
initialRandomDBPassphrase: MutableState<Boolean>,
migration: Boolean,
) {
@@ -33,6 +33,23 @@ class CallServerAuthTest {
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
}
@Test
fun testWebSocketUpgradeRejectedFromOtherOrigin() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=$token", origin = "http://example.com")))
}
@Test
fun testSecondWebSocketUpgradeRejected() {
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=$token")))
}
@Test
fun testWebSocketUpgradeAcceptedAfterWrongToken() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=wrong")))
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
}
@Test
fun testCallPageRejectedWithoutToken() {
assertEquals(401, requestStatus(get(path = "/simplex/call/")))
@@ -46,10 +63,11 @@ class CallServerAuthTest {
private fun get(path: String): List<String> = listOf("GET $path HTTP/1.1", "Host: localhost:$port")
private fun webSocketUpgrade(path: String): List<String> =
private fun webSocketUpgrade(path: String, origin: String = "http://localhost:$port"): List<String> =
listOf(
"GET $path HTTP/1.1",
"Host: localhost:$port",
"Origin: $origin",
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",