Merge branch 'master' into master-ghc9

This commit is contained in:
Evgeny Poberezkin
2023-09-10 21:11:35 +01:00
226 changed files with 16182 additions and 2495 deletions
@@ -23,6 +23,8 @@ actual val agentDatabaseFileName: String = "files_agent.db"
actual val databaseExportDir: File = androidAppContext.cacheDir
actual fun desktopOpenDatabaseDir() {}
@Composable
actual fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any?, onResult: (URI?) -> Unit): FileChooserLauncher {
val launcher = rememberLauncherForActivityResult(
@@ -1,6 +1,5 @@
package chat.simplex.common.platform
import android.app.Application
import android.content.Context
import android.media.*
import android.media.AudioManager.AudioPlaybackCallback
@@ -8,10 +7,10 @@ import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
import android.os.Build
import androidx.compose.runtime.*
import chat.simplex.res.MR
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.*
import chat.simplex.common.platform.AudioPlayer.duration
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import java.io.*
@@ -134,20 +133,25 @@ actual object AudioPlayer: AudioPlayerInterface {
}
// Returns real duration of the track
private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
if (!File(filePath).exists()) {
Log.e(TAG, "No such file: $filePath")
private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
val absoluteFilePath = getAppFilePath(fileSource.filePath)
if (!File(absoluteFilePath).exists()) {
Log.e(TAG, "No such file: ${fileSource.filePath}")
return null
}
VideoPlayer.stopAll()
RecorderInterface.stopRecording?.invoke()
val current = currentlyPlaying.value
if (current == null || current.first != filePath) {
if (current == null || current.first != fileSource.filePath) {
stopListener()
player.reset()
runCatching {
player.setDataSource(filePath)
if (fileSource.cryptoArgs != null) {
player.setDataSource(CryptoMediaSource(readCryptoFile(absoluteFilePath, fileSource.cryptoArgs)))
} else {
player.setDataSource(absoluteFilePath)
}
}.onFailure {
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message)
@@ -162,7 +166,7 @@ actual object AudioPlayer: AudioPlayerInterface {
}
if (seek != null) player.seekTo(seek)
player.start()
currentlyPlaying.value = filePath to onProgressUpdate
currentlyPlaying.value = fileSource.filePath to onProgressUpdate
progressJob = CoroutineScope(Dispatchers.Default).launch {
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
while(isActive && player.isPlaying) {
@@ -229,7 +233,7 @@ actual object AudioPlayer: AudioPlayerInterface {
}
override fun play(
filePath: String?,
fileSource: CryptoFile,
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
@@ -238,7 +242,7 @@ actual object AudioPlayer: AudioPlayerInterface {
if (progress.value == duration.value) {
progress.value = 0
}
val realDuration = start(filePath ?: return, progress.value) { pro, state ->
val realDuration = start(fileSource, progress.value) { pro, state ->
if (pro != null) {
progress.value = pro
}
@@ -283,3 +287,21 @@ actual object AudioPlayer: AudioPlayerInterface {
}
actual typealias SoundPlayer = chat.simplex.common.helpers.SoundPlayer
class CryptoMediaSource(val data: ByteArray) : MediaDataSource() {
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
if (position >= data.size) return -1
val endPosition: Int = (position + size).toInt()
var sizeLeft: Int = size
if (endPosition > data.size) {
sizeLeft -= endPosition - data.size
}
System.arraycopy(data, position.toInt(), buffer, offset, sizeLeft)
return sizeLeft
}
override fun getSize(): Long = data.size.toLong()
override fun close() {}
}
@@ -8,13 +8,15 @@ import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
import chat.simplex.common.helpers.toUri
import chat.simplex.common.model.CIFile
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.common.views.helpers.getAppFileUri
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import chat.simplex.common.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import java.io.BufferedOutputStream
import java.io.File
import chat.simplex.res.MR
import java.io.ByteArrayOutputStream
actual fun ClipboardManager.shareText(text: String) {
val sendIntent: Intent = Intent().apply {
@@ -28,9 +30,17 @@ actual fun ClipboardManager.shareText(text: String) {
androidAppContext.startActivity(shareIntent)
}
actual fun shareFile(text: String, filePath: String) {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
val ext = filePath.substringAfterLast(".")
actual fun shareFile(text: String, fileSource: CryptoFile) {
val uri = if (fileSource.cryptoArgs != null) {
val tmpFile = File(tmpDir, fileSource.filePath)
tmpFile.deleteOnExit()
ChatModel.filesToDelete.add(tmpFile)
decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath)
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(tmpFile.absolutePath)).toURI()
} else {
getAppFileUri(fileSource.filePath)
}
val ext = fileSource.filePath.substringAfterLast(".")
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
@@ -84,8 +94,16 @@ fun saveImage(ciFile: CIFile?) {
uri?.let {
androidAppContext.contentResolver.openOutputStream(uri)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
File(filePath).inputStream().use { it.copyTo(outputStream) }
outputStream.close()
if (ciFile.fileSource?.cryptoArgs != null) {
createTmpFileAndDelete { tmpFile ->
decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath)
tmpFile.inputStream().use { it.copyTo(outputStream) }
}
outputStream.close()
} else {
File(filePath).inputStream().use { it.copyTo(outputStream) }
outputStream.close()
}
showToast(generalGetString(MR.strings.image_saved))
}
}
@@ -19,7 +19,7 @@ import java.net.URI
@Composable
actual fun SimpleAndAnimatedImageView(
uri: URI,
data: ByteArray,
imageBitmap: ImageBitmap,
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
@@ -27,7 +27,7 @@ actual fun SimpleAndAnimatedImageView(
) {
val context = LocalContext.current
val imagePainter = rememberAsyncImagePainter(
ImageRequest.Builder(context).data(data = uri.toUri()).size(coil.size.Size.ORIGINAL).build(),
ImageRequest.Builder(context).data(data = data).size(coil.size.Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil
imageLoader = imageLoader
)
@@ -26,7 +26,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) {
@Composable
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) {
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = {
ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
@@ -26,7 +26,7 @@ import dev.icerock.moko.resources.compose.stringResource
import java.net.URI
@Composable
actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) {
actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) {
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
val imageLoader = ImageLoader.Builder(LocalContext.current)
@@ -40,7 +40,7 @@ actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageB
.build()
Image(
rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current).data(data = uri.toUri()).size(Size.ORIGINAL).build(),
ImageRequest.Builder(LocalContext.current).data(data = data).size(Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil
imageLoader = imageLoader
),
@@ -0,0 +1,106 @@
package chat.simplex.common.views.database
import SectionItemView
import SectionTextFooter
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.SimplexGreen
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
actual fun SavePassphraseSetting(
useKeychain: Boolean,
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
minHeight: Dp,
onCheckedChange: (Boolean) -> Unit,
) {
SectionItemView(minHeight = minHeight) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled),
stringResource(MR.strings.save_passphrase_in_keychain),
tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(MR.strings.save_passphrase_in_keychain),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
enabled = !initialRandomDBPassphrase && !progressIndicator
)
}
}
}
@Composable
actual fun DatabaseEncryptionFooter(
useKeychain: MutableState<Boolean>,
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
) {
if (chatDbEncrypted == false) {
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely))
if (initialRandomDBPassphrase.value) {
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
} else {
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
}
} else {
SectionTextFooter(generalGetString(MR.strings.keychain_allows_to_receive_ntfs))
}
} else {
SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time))
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
}
}
actual fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.encrypt_database_question),
text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(MR.strings.encrypt_database),
onConfirm = onConfirm,
destructive = true,
)
}
actual fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.change_database_passphrase_question),
text = generalGetString(MR.strings.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(MR.strings.update_database),
onConfirm = onConfirm,
destructive = false,
)
}
actual fun removePassphraseAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.remove_passphrase_from_keychain),
text = generalGetString(MR.strings.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
confirmText = generalGetString(MR.strings.remove_passphrase),
onConfirm = onConfirm,
destructive = true,
)
}
@@ -1,6 +1,5 @@
package chat.simplex.common.views.helpers
import android.app.Application
import android.content.res.Resources
import android.graphics.*
import android.graphics.Typeface
@@ -12,11 +11,8 @@ import android.text.Spanned
import android.text.SpannedString
import android.text.style.*
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.BaselineShift
@@ -159,17 +155,18 @@ actual fun getAppFileUri(fileName: String): URI =
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(getAppFilePath(fileName))).toURI()
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
actual fun getLoadedImage(file: CIFile?): ImageBitmap? {
actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
val filePath = getLoadedFilePath(file)
return if (filePath != null) {
return if (filePath != null && file != null) {
try {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
val parcelFileDescriptor = androidAppContext.contentResolver.openFileDescriptor(uri.toUri(), "r")
val fileDescriptor = parcelFileDescriptor?.fileDescriptor
val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000)
parcelFileDescriptor?.close()
image.asImageBitmap()
val data = if (file.fileSource?.cryptoArgs != null) {
readCryptoFile(getAppFilePath(file.fileSource.filePath), file.fileSource.cryptoArgs)
} else {
File(getAppFilePath(file.fileName)).readBytes()
}
decodeSampledBitmapFromByteArray(data, 1000, 1000).asImageBitmap() to data
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
null
}
} else {
@@ -178,17 +175,17 @@ actual fun getLoadedImage(file: CIFile?): ImageBitmap? {
}
// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap {
private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap {
// First decode with inJustDecodeBounds=true to check dimensions
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
BitmapFactory.decodeByteArray(data, 0, data.size)
// Calculate inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
inJustDecodeBounds = false
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
BitmapFactory.decodeByteArray(data, 0, data.size)
}
}
@@ -254,6 +251,26 @@ actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitma
}?.asImageBitmap()
}
actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? {
return if (Build.VERSION.SDK_INT >= 31) {
val source = ImageDecoder.createSource(data)
try {
ImageDecoder.decodeBitmap(source)
} catch (e: android.graphics.ImageDecoder.DecodeException) {
Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
if (withAlertOnException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.image_decoding_exception_title),
text = generalGetString(MR.strings.image_decoding_exception_desc)
)
}
null
}
} else {
BitmapFactory.decodeByteArray(data, 0, data.size)
}?.asImageBitmap()
}
actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? {
return if (Build.VERSION.SDK_INT >= 28) {
val source = ImageDecoder.createSource(androidAppContext.contentResolver, uri.toUri())
@@ -1,5 +1,6 @@
#include <jni.h>
//#include <string.h>
#include <string.h>
#include <stdint.h>
//#include <stdlib.h>
//#include <android/log.h>
@@ -45,6 +46,10 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_write_file(const char *path, char *ptr, int length);
extern char *chat_read_file(const char *path, const char *key, const char *nonce);
extern char *chat_encrypt_file(const char *from_path, const char *to_path);
extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path);
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
@@ -115,3 +120,76 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused
(*env)->ReleaseStringUTFChars(env, salt, _salt);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) {
const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE);
jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer);
jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer);
jstring res = (*env)->NewStringUTF(env, chat_write_file(_path, buff, capacity));
(*env)->ReleaseStringUTFChars(env, path, _path);
return res;
}
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, jstring path, jstring key, jstring nonce) {
const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE);
const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE);
const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE);
jbyte *res = chat_read_file(_path, _key, _nonce);
(*env)->ReleaseStringUTFChars(env, path, _path);
(*env)->ReleaseStringUTFChars(env, key, _key);
(*env)->ReleaseStringUTFChars(env, nonce, _nonce);
jint status = (jint)res[0];
jbyteArray arr;
if (status == 0) {
union {
uint32_t w;
uint8_t b[4];
} len;
len.b[0] = (uint8_t)res[1];
len.b[1] = (uint8_t)res[2];
len.b[2] = (uint8_t)res[3];
len.b[3] = (uint8_t)res[4];
arr = (*env)->NewByteArray(env, len.w);
(*env)->SetByteArrayRegion(env, arr, 0, len.w, res + 5);
} else {
int len = strlen(res + 1); // + 1 offset here is to not include status byte
arr = (*env)->NewByteArray(env, len);
(*env)->SetByteArrayRegion(env, arr, 0, len, res + 1);
}
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"),
(*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Integer"), "<init>", "(I)V"),
status);
(*env)->SetObjectArrayElement(env, ret, 0, statusObj);
(*env)->SetObjectArrayElement(env, ret, 1, arr);
return ret;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) {
const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE);
const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_encrypt_file(_from_path, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, to_path, _to_path);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring key, jstring nonce, jstring to_path) {
const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE);
const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE);
const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE);
const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, key, _key);
(*env)->ReleaseStringUTFChars(env, nonce, _nonce);
(*env)->ReleaseStringUTFChars(env, to_path, _to_path);
return res;
}
@@ -1,6 +1,7 @@
#include <jni.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
// from the RTS
void hs_init(int * argc, char **argv[]);
@@ -20,7 +21,10 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_write_file(const char *path, char *ptr, int length);
extern char *chat_read_file(const char *path, const char *key, const char *nonce);
extern char *chat_encrypt_file(const char *from_path, const char *to_path);
extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path);
// As a reference: https://stackoverflow.com/a/60002045
jstring decode_to_utf8_string(JNIEnv *env, char *string) {
@@ -128,3 +132,76 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass cl
(*env)->ReleaseStringUTFChars(env, salt, _salt);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) {
const char *_path = encode_to_utf8_chars(env, path);
jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer);
jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer);
jstring res = decode_to_utf8_string(env, chat_write_file(_path, buff, capacity));
(*env)->ReleaseStringUTFChars(env, path, _path);
return res;
}
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, jstring path, jstring key, jstring nonce) {
const char *_path = encode_to_utf8_chars(env, path);
const char *_key = encode_to_utf8_chars(env, key);
const char *_nonce = encode_to_utf8_chars(env, nonce);
jbyte *res = chat_read_file(_path, _key, _nonce);
(*env)->ReleaseStringUTFChars(env, path, _path);
(*env)->ReleaseStringUTFChars(env, key, _key);
(*env)->ReleaseStringUTFChars(env, nonce, _nonce);
jint status = (jint)res[0];
jbyteArray arr;
if (status == 0) {
union {
uint32_t w;
uint8_t b[4];
} len;
len.b[0] = (uint8_t)res[1];
len.b[1] = (uint8_t)res[2];
len.b[2] = (uint8_t)res[3];
len.b[3] = (uint8_t)res[4];
arr = (*env)->NewByteArray(env, len.w);
(*env)->SetByteArrayRegion(env, arr, 0, len.w, res + 5);
} else {
int len = strlen(res + 1); // + 1 offset here is to not include status byte
arr = (*env)->NewByteArray(env, len);
(*env)->SetByteArrayRegion(env, arr, 0, len, res + 1);
}
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"),
(*env)->GetMethodID(env, (*env)->FindClass(env, "java/lang/Integer"), "<init>", "(I)V"),
status);
(*env)->SetObjectArrayElement(env, ret, 0, statusObj);
(*env)->SetObjectArrayElement(env, ret, 1, arr);
return ret;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) {
const char *_from_path = encode_to_utf8_chars(env, from_path);
const char *_to_path = encode_to_utf8_chars(env, to_path);
jstring res = decode_to_utf8_string(env, chat_encrypt_file(_from_path, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, to_path, _to_path);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring key, jstring nonce, jstring to_path) {
const char *_from_path = encode_to_utf8_chars(env, from_path);
const char *_key = encode_to_utf8_chars(env, key);
const char *_nonce = encode_to_utf8_chars(env, nonce);
const char *_to_path = encode_to_utf8_chars(env, to_path);
jstring res = decode_to_utf8_string(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path));
(*env)->ReleaseStringUTFChars(env, from_path, _from_path);
(*env)->ReleaseStringUTFChars(env, key, _key);
(*env)->ReleaseStringUTFChars(env, nonce, _nonce);
(*env)->ReleaseStringUTFChars(env, to_path, _to_path);
return res;
}
@@ -32,8 +32,7 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.*
data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>,
@@ -64,7 +63,7 @@ fun MainScreen() {
if (
!chatModel.controller.appPrefs.laNoticeShown.get()
&& showAdvertiseLAAlert
&& chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
&& chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete
&& chatModel.chats.isNotEmpty()
&& chatModel.activeCallInvitation.value == null
) {
@@ -102,7 +101,10 @@ fun MainScreen() {
}
Box {
val onboarding = chatModel.onboardingStage.value
var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) }
LaunchedEffect(Unit) {
snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it }
}
val userCreated = chatModel.userCreated.value
var showInitializationView by remember { mutableStateOf(false) }
when {
@@ -112,7 +114,7 @@ fun MainScreen() {
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
}
}
onboarding == null || userCreated == null -> SplashView()
remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
Box {
showAdvertiseLAAlert = true
@@ -134,6 +136,7 @@ fun MainScreen() {
}
}
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel)
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
}
@@ -194,24 +197,25 @@ fun AndroidScreen(settingsState: SettingsViewState) {
StartPartOfScreen(settingsState)
}
val scope = rememberCoroutineScope()
val onComposed: () -> Unit = {
val onComposed: suspend (chatId: String?) -> Unit = { chatId ->
// coroutine, scope and join() because:
// - it should be run from coroutine to wait until this function finishes
// - without using scope.launch it throws CancellationException when changing user
// - join allows to wait until completion
scope.launch {
offset.animateTo(
if (chatModel.chatId.value == null) 0f else maxWidth.value,
if (chatId == null) 0f else maxWidth.value,
chatListAnimationSpec()
)
if (offset.value == 0f) {
currentChatId = null
}
}
}.join()
}
LaunchedEffect(Unit) {
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (it != null) currentChatId = it
else onComposed()
if (it == null) onComposed(null)
currentChatId = it
}
}
}
@@ -13,6 +13,7 @@ import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.platform.AudioPlayer
import chat.simplex.common.platform.chatController
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
@@ -38,7 +39,6 @@ import kotlin.time.*
@Stable
object ChatModel {
val controller: ChatController = ChatController
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
val setDeliveryReceipts = mutableStateOf(false)
val currentUser = mutableStateOf<User?>(null)
val users = mutableStateListOf<UserInfo>()
@@ -1395,6 +1395,13 @@ data class ChatItem (
private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID
val encryptedFile: Boolean? = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null
val encryptLocalFile: Boolean
get() = file?.fileProtocol == FileProtocol.XFTP &&
content.msgContent !is MsgContent.MCVideo &&
chatController.appPrefs.privacyEncryptLocalFiles.get()
val memberDisplayName: String? get() =
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
else null
@@ -2025,7 +2032,7 @@ class CIFile(
val fileId: Long,
val fileName: String,
val fileSize: Long,
val filePath: String? = null,
val fileSource: CryptoFile? = null,
val fileStatus: CIFileStatus,
val fileProtocol: FileProtocol
) {
@@ -2073,10 +2080,23 @@ class CIFile(
filePath: String? = "test.txt",
fileStatus: CIFileStatus = CIFileStatus.RcvComplete
): CIFile =
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)
}
}
@Serializable
data class CryptoFile(
val filePath: String,
val cryptoArgs: CryptoFileArgs?
) {
companion object {
fun plain(f: String): CryptoFile = CryptoFile(f, null)
}
}
@Serializable
data class CryptoFileArgs(val fileKey: String, val fileNonce: String)
class CancelAction(
val uiActionId: StringResource,
val alert: AlertInfo
@@ -0,0 +1,59 @@
package chat.simplex.common.model
import chat.simplex.common.platform.*
import kotlinx.serialization.*
import java.nio.ByteBuffer
@Serializable
sealed class WriteFileResult {
@Serializable @SerialName("result") data class Result(val cryptoArgs: CryptoFileArgs): WriteFileResult()
@Serializable @SerialName("error") data class Error(val writeError: String): WriteFileResult()
}
/*
fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs {
val str = chatWriteFile(path, data)
return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) {
is WriteFileResult.Result -> d.cryptoArgs
is WriteFileResult.Error -> throw Exception(d.writeError)
}
}
* */
fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs {
val buffer = ByteBuffer.allocateDirect(data.size)
buffer.put(data)
buffer.rewind()
val str = chatWriteFile(path, buffer)
return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) {
is WriteFileResult.Result -> d.cryptoArgs
is WriteFileResult.Error -> throw Exception(d.writeError)
}
}
fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray {
val res: Array<Any> = chatReadFile(path, cryptoArgs.fileKey, cryptoArgs.fileNonce)
val status = (res[0] as Integer).toInt()
val arr = res[1] as ByteArray
if (status == 0) {
return arr
} else {
throw Exception(String(arr))
}
}
fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs {
val str = chatEncryptFile(fromPath, toPath)
val d = json.decodeFromString(WriteFileResult.serializer(), str)
return when (d) {
is WriteFileResult.Result -> d.cryptoArgs
is WriteFileResult.Error -> throw Exception(d.writeError)
}
}
fun decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) {
val err = chatDecryptFile(fromPath, cryptoArgs.fileKey, cryptoArgs.fileNonce, toPath)
if (err != "") {
throw Exception(err)
}
}
@@ -5,7 +5,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
@@ -94,6 +93,7 @@ class AppPreferences {
val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true)
val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true)
val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false)
val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
@@ -249,6 +249,7 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews"
private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft"
private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet"
private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles"
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites"
@@ -586,7 +587,7 @@ object ChatController {
return null
}
suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? {
suspend fun apiSendMessage(type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? {
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl)
val r = sendCmd(cmd)
return when (r) {
@@ -1079,8 +1080,8 @@ object ChatController {
return false
}
suspend fun apiReceiveFile(fileId: Long, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId, inline))
suspend fun apiReceiveFile(fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline))
return when (r) {
is CR.RcvFileAccepted -> r.chatItem
is CR.RcvFileAcceptedSndCancelled -> {
@@ -1413,7 +1414,7 @@ object ChatController {
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
withApi { receiveFile(r.user, file.fileId, auto = true) }
withApi { receiveFile(r.user, file.fileId, encrypted = cItem.encryptLocalFile && chatController.appPrefs.privacyEncryptLocalFiles.get(), auto = true) }
}
if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
@@ -1647,8 +1648,8 @@ object ChatController {
}
}
suspend fun receiveFile(user: UserLike, fileId: Long, auto: Boolean = false) {
val chatItem = apiReceiveFile(fileId, auto = auto)
suspend fun receiveFile(user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) {
val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto)
if (chatItem != null) {
chatItemSimpleUpdate(user, chatItem)
}
@@ -1804,7 +1805,7 @@ sealed class CC {
class ApiGetChats(val userId: Long): CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: CryptoFile?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC()
@@ -1867,7 +1868,7 @@ sealed class CC {
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long, val inline: Boolean?): CC()
class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC()
class CancelFile(val fileId: Long): CC()
class ShowVersion(): CC()
@@ -1972,7 +1973,7 @@ sealed class CC {
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
is ReceiveFile -> if (inline == null) "/freceive $fileId" else "/freceive $fileId inline=${onOff(inline)}"
is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}")
is CancelFile -> "/fcancel $fileId"
is ShowVersion -> "/version"
}
@@ -2134,7 +2135,7 @@ sealed class ChatPagination {
}
@Serializable
class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent)
class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent)
@Serializable
class XFTPFileConfig(val minFileSize: Long)
@@ -1,8 +1,9 @@
package chat.simplex.common.platform
import chat.simplex.common.BuildConfigCommon
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.DefaultTheme
import java.io.File
import java.util.*
enum class AppPlatform {
@@ -4,6 +4,7 @@ import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import kotlinx.serialization.decodeFromString
import java.nio.ByteBuffer
// ghc's rts
external fun initHS()
@@ -19,6 +20,10 @@ external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
external fun chatParseServer(str: String): String
external fun chatPasswordHash(pwd: String, salt: String): String
external fun chatWriteFile(path: String, buffer: ByteBuffer): String
external fun chatReadFile(path: String, key: String, nonce: String): Array<Any>
external fun chatEncryptFile(fromPath: String, toPath: String): String
external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String
val chatModel: ChatModel
get() = chatController.chatModel
@@ -50,17 +55,16 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
if (user == null) {
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
chatModel.currentUser.value = null
chatModel.users.clear()
} else {
val savedOnboardingStage = appPreferences.onboardingStage.get()
chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
OnboardingStage.Step3_CreateSimpleXAddress
} else {
savedOnboardingStage
}
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) {
})
if (appPreferences.onboardingStage.get() == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) {
chatModel.setDeliveryReceipts.value = true
}
chatController.startChat(user)
@@ -2,6 +2,7 @@ package chat.simplex.common.platform
import androidx.compose.runtime.Composable
import chat.simplex.common.model.CIFile
import chat.simplex.common.model.CryptoFile
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import java.io.*
@@ -23,6 +24,8 @@ expect val agentDatabaseFileName: String
* */
expect val databaseExportDir: File
expect fun desktopOpenDatabaseDir()
fun copyFileToFile(from: File, to: URI, finally: () -> Unit) {
try {
to.outputStream().use { stream ->
@@ -60,14 +63,25 @@ fun getAppFilePath(fileName: String): String {
}
fun getLoadedFilePath(file: CIFile?): String? {
return if (file?.filePath != null && file.loaded) {
val filePath = getAppFilePath(file.filePath)
val f = file?.fileSource?.filePath
return if (f != null && file.loaded) {
val filePath = getAppFilePath(f)
if (File(filePath).exists()) filePath else null
} else {
null
}
}
fun getLoadedFileSource(file: CIFile?): CryptoFile? {
val f = file?.fileSource?.filePath
return if (f != null && file.loaded) {
val filePath = getAppFilePath(f)
if (File(filePath).exists()) file.fileSource else null
} else {
null
}
}
/**
* [rememberedValue] is used in `remember(rememberedValue)`. So when the value changes, file saver will update a callback function
* */
@@ -100,7 +100,7 @@ abstract class NtfManager {
if (chatModel.chatRunning.value == null) {
val step = 50L
for (i in 0..(timeout / step)) {
if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
if (chatModel.chatRunning.value == true || chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.Step1_SimpleXInfo) {
break
}
delay(step)
@@ -1,7 +1,7 @@
package chat.simplex.common.platform
import androidx.compose.runtime.MutableState
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.*
import kotlinx.coroutines.CoroutineScope
interface RecorderInterface {
@@ -18,7 +18,7 @@ expect class RecorderNative(): RecorderInterface
interface AudioPlayerInterface {
fun play(
filePath: String?,
fileSource: CryptoFile,
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
@@ -2,8 +2,9 @@ package chat.simplex.common.platform
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
import chat.simplex.common.model.CryptoFile
expect fun UriHandler.sendEmail(subject: String, body: CharSequence)
expect fun ClipboardManager.shareText(text: String)
expect fun shareFile(text: String, filePath: String)
expect fun shareFile(text: String, fileSource: CryptoFile)
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.Profile
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.navigationBarsWithImePadding
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -88,14 +89,20 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
icon = painterResource(MR.images.ic_arrow_back_ios_new),
textDecoration = TextDecoration.None,
fontWeight = FontWeight.Medium
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) }
}
Spacer(Modifier.fillMaxWidth().weight(1f))
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
val createModifier: Modifier
val createColor: Color
if (enabled) {
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp)
createModifier = Modifier.clickable {
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
createProfileInProfiles(chatModel, displayName.value, fullName.value, close)
} else {
createProfileOnboarding(chatModel, displayName.value, fullName.value, close)
}
}.padding(8.dp)
createColor = MaterialTheme.colors.primary
} else {
createModifier = Modifier.padding(8.dp)
@@ -116,7 +123,7 @@ fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
}
}
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
withApi {
val user = chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName, null)
@@ -125,16 +132,32 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, c
if (chatModel.users.isEmpty()) {
chatModel.controller.startChat(user)
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
chatModel.onboardingStage.value = OnboardingStage.Step3_CreateSimpleXAddress
} else {
val users = chatModel.controller.listUsers()
chatModel.users.clear()
chatModel.users.addAll(users)
chatModel.controller.getUserChatData()
close()
}
}
}
fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
withApi {
chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName, null)
) ?: return@withApi
val onboardingStage = chatModel.controller.appPrefs.onboardingStage
if (chatModel.users.isEmpty()) {
onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get()) {
OnboardingStage.Step2_5_SetupDatabasePassphrase
} else {
OnboardingStage.Step3_CreateSimpleXAddress
})
} else {
// the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen,
// this will get it unstuck.
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete)
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
onboardingStage.set(OnboardingStage.OnboardingComplete)
close()
}
}
@@ -24,6 +24,7 @@ import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -278,7 +279,7 @@ fun ChatInfoLayout(
ChatInfoHeader(chat.chatInfo, contact)
}
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged)
SectionSpacer()
if (customUserProfile != null) {
SectionView(generalGetString(MR.strings.incognito).uppercase()) {
@@ -403,13 +404,16 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
@Composable
fun LocalAliasEditor(
chatId: String,
initialValue: String,
center: Boolean = true,
leadingIcon: Boolean = false,
focus: Boolean = false,
updateValue: (String) -> Unit
) {
var value by rememberSaveable { mutableStateOf(initialValue) }
val state = remember(chatId) {
mutableStateOf(TextFieldValue(initialValue))
}
var updatedValueAtLeastOnce = remember { false }
val modifier = if (center)
Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp)
@@ -418,7 +422,7 @@ fun LocalAliasEditor(
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (center) Arrangement.Center else Arrangement.Start) {
DefaultBasicTextField(
modifier,
value,
state,
{
Text(
generalGetString(MR.strings.text_field_set_contact_placeholder),
@@ -431,27 +435,27 @@ fun LocalAliasEditor(
} else null,
color = MaterialTheme.colors.secondary,
focus = focus,
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
textStyle = TextStyle.Default.copy(textAlign = if (state.value.text.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
keyboardActions = KeyboardActions(onDone = { updateValue(state.value.text) })
) {
value = it
state.value = it
updatedValueAtLeastOnce = true
}
}
LaunchedEffect(Unit) {
var prevValue = value
snapshotFlow { value }
LaunchedEffect(chatId) {
var prevValue = state.value
snapshotFlow { state.value }
.distinctUntilChanged()
.onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing
.conflate() // get the latest value
.filter { it == value && it != prevValue } // don't process old ones
.filter { it == state.value && it != prevValue } // don't process old ones
.collect {
updateValue(it)
updateValue(it.text)
prevValue = it
}
}
DisposableEffect(Unit) {
onDispose { if (updatedValueAtLeastOnce) updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast
DisposableEffect(chatId) {
onDispose { if (updatedValueAtLeastOnce) updateValue(state.value.text) } // just in case snapshotFlow will be canceled when user presses Back too fast
}
}
@@ -43,7 +43,7 @@ import java.net.URI
import kotlin.math.sign
@Composable
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: String) -> Unit) {
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
val searchText = rememberSaveable { mutableStateOf("") }
val user = chatModel.currentUser.value
@@ -66,12 +66,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.filter { it != null && activeChat.value?.id != it }
.collect { chatId ->
if (activeChat.value?.id != chatId && chatId != null) {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
activeChat.value = chatModel.getChat(chatId)
}
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
activeChat.value = chatModel.getChat(chatId!!)
markUnreadChatAsRead(activeChat, chatModel)
}
}
@@ -91,7 +90,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
}
.distinctUntilChanged()
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
.filter { it?.chatInfo != activeChat.value?.chatInfo && it != null }
.filter { it != null && it?.chatInfo != activeChat.value?.chatInfo }
.collect { activeChat.value = it }
}
}
@@ -245,8 +244,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
}
}
},
receiveFile = { fileId ->
withApi { chatModel.controller.receiveFile(user, fileId) }
receiveFile = { fileId, encrypted ->
withApi { chatModel.controller.receiveFile(user, fileId, encrypted) }
},
cancelFile = { fileId ->
withApi { chatModel.controller.cancelFile(user, fileId) }
@@ -404,7 +403,7 @@ fun ChatLayout(
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
@@ -422,7 +421,7 @@ fun ChatLayout(
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
onComposed: () -> Unit,
onComposed: suspend (chatId: String) -> Unit,
) {
val scope = rememberCoroutineScope()
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
@@ -657,7 +656,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
@@ -672,7 +671,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
showItemDetails: (ChatInfo, ChatItem) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: () -> Unit,
onComposed: suspend (chatId: String) -> Unit,
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
@@ -703,13 +702,13 @@ fun BoxWithConstraintsScope.ChatItemsList(
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
}
}
LaunchedEffect(Unit) {
LaunchedEffect(chat.id) {
var stopListening = false
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
.distinctUntilChanged()
.filter { !stopListening }
.collect {
onComposed()
onComposed(chat.id)
stopListening = true
}
}
@@ -1118,7 +1117,7 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha
}
sealed class ProviderMedia {
data class Image(val uri: URI, val image: ImageBitmap): ProviderMedia()
data class Image(val data: ByteArray, val image: ImageBitmap): ProviderMedia()
data class Video(val uri: URI, val preview: String): ProviderMedia()
}
@@ -1156,11 +1155,11 @@ private fun providerForGallery(
val item = item(internalIndex, initialChatId)?.second ?: return null
return when (item.content.msgContent) {
is MsgContent.MCImage -> {
val imageBitmap: ImageBitmap? = getLoadedImage(item.file)
val res = getLoadedImage(item.file)
val filePath = getLoadedFilePath(item.file)
if (imageBitmap != null && filePath != null) {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
ProviderMedia.Image(uri, imageBitmap)
if (res != null && filePath != null) {
val (imageBitmap: ImageBitmap, data: ByteArray) = res
ProviderMedia.Image(data, imageBitmap)
} else null
}
is MsgContent.MCVideo -> {
@@ -1258,7 +1257,7 @@ fun PreviewChatLayout() {
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
startCall = {},
@@ -1325,7 +1324,7 @@ fun PreviewGroupChatLayout() {
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
startCall = {},
@@ -317,7 +317,7 @@ fun ComposeView(
chatModel.filesToDelete.clear()
}
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false, ttl: Int?): ChatItem? {
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? {
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
@@ -331,7 +331,7 @@ fun ComposeView(
chatModel.addChatItem(cInfo, aChatItem.chatItem)
return aChatItem.chatItem
}
if (file != null) removeFile(file)
if (file != null) removeFile(file.filePath)
return null
}
@@ -404,16 +404,16 @@ fun ComposeView(
sent = updateMessage(liveMessage.chatItem, cInfo, live)
} else {
val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<String> = ArrayList()
val files: ArrayList<CryptoFile> = ArrayList()
when (val preview = cs.preview) {
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.MediaPreview -> {
preview.content.forEachIndexed { index, it ->
val file = when (it) {
is UploadContent.SimpleImage -> saveImage(it.uri)
is UploadContent.AnimatedImage -> saveAnimImage(it.uri)
is UploadContent.Video -> saveFileFromUri(it.uri)
is UploadContent.SimpleImage -> saveImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get())
is UploadContent.AnimatedImage -> saveAnimImage(it.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get())
is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false)
}
if (file != null) {
files.add(file)
@@ -429,15 +429,21 @@ fun ComposeView(
val tmpFile = File(preview.voice)
AudioPlayer.stop(tmpFile.absolutePath)
val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, "")))
withContext(Dispatchers.IO) {
Files.move(tmpFile.toPath(), actualFile.toPath())
}
files.add(actualFile.name)
files.add(withContext(Dispatchers.IO) {
if (chatController.appPrefs.privacyEncryptLocalFiles.get()) {
val args = encryptCryptoFile(tmpFile.absolutePath, actualFile.absolutePath)
tmpFile.delete()
CryptoFile(actualFile.name, args)
} else {
Files.move(tmpFile.toPath(), actualFile.toPath())
CryptoFile.plain(actualFile.name)
}
})
deleteUnusedFiles()
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
}
is ComposePreview.FilePreview -> {
val file = saveFileFromUri(preview.uri)
val file = saveFileFromUri(preview.uri, encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get())
if (file != null) {
files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
@@ -17,6 +17,7 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.CryptoFile
import chat.simplex.common.model.durationText
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -52,7 +53,7 @@ fun ComposeVoiceView(
IconButton(
onClick = {
if (!audioPlaying.value) {
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
AudioPlayer.play(CryptoFile.plain(filePath), audioPlaying, progress, duration, false)
} else {
AudioPlayer.pause(audioPlaying, progress)
}
@@ -28,7 +28,7 @@ import java.net.URI
fun CIFileView(
file: CIFile?,
edited: Boolean,
receiveFile: (Long) -> Unit
receiveFile: (Long, Boolean) -> Unit
) {
val saveFileLauncher = rememberSaveFileLauncher(ciFile = file)
@@ -71,7 +71,8 @@ fun CIFileView(
when (file.fileStatus) {
is CIFileStatus.RcvInvitation -> {
if (fileSizeValid()) {
receiveFile(file.fileId)
val encrypted = file.fileProtocol == FileProtocol.XFTP && chatController.appPrefs.privacyEncryptLocalFiles.get()
receiveFile(file.fileId, encrypted)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -184,9 +185,9 @@ fun CIFileView(
) {
fileIndicator()
val metaReserve = if (edited)
" "
" "
else
" "
" "
if (file != null) {
Column {
Text(
@@ -211,7 +212,15 @@ fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
rememberFileChooserLauncher(false, ciFile) { to: URI? ->
val filePath = getLoadedFilePath(ciFile)
if (filePath != null && to != null) {
copyFileToFile(File(filePath), to) {}
if (ciFile?.fileSource?.cryptoArgs != null) {
createTmpFileAndDelete { tmpFile ->
decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath)
copyFileToFile(tmpFile, to) {}
tmpFile.delete()
}
} else {
copyFileToFile(File(filePath), to) {}
}
}
}
@@ -29,9 +29,11 @@ import java.net.URI
fun CIImageView(
image: String,
file: CIFile?,
encryptLocalFile: Boolean,
metaColor: Color,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
receiveFile: (Long, Boolean) -> Unit
) {
@Composable
fun progressIndicator() {
@@ -48,7 +50,7 @@ fun CIImageView(
icon,
stringResource(stringId),
Modifier.fillMaxSize(),
tint = Color.White
tint = metaColor
)
}
@@ -132,27 +134,31 @@ fun CIImageView(
return false
}
fun imageAndFilePath(file: CIFile?): Pair<ImageBitmap?, String?> {
val imageBitmap: ImageBitmap? = getLoadedImage(file)
val filePath = getLoadedFilePath(file)
return imageBitmap to filePath
fun imageAndFilePath(file: CIFile?): Triple<ImageBitmap, ByteArray, String>? {
val res = getLoadedImage(file)
if (res != null) {
val (imageBitmap: ImageBitmap, data: ByteArray) = res
val filePath = getLoadedFilePath(file)!!
return Triple(imageBitmap, data, filePath)
}
return null
}
Box(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
contentAlignment = Alignment.TopEnd
) {
val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) }
if (imageBitmap != null && filePath != null) {
val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) }
SimpleAndAnimatedImageView(uri, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) })
val res = remember(file) { imageAndFilePath(file) }
if (res != null) {
val (imageBitmap, data, _) = res
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) })
} else {
imageView(base64ToBitmap(image), onClick = {
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
if (fileSizeValid()) {
receiveFile(file.fileId)
receiveFile(file.fileId, encryptLocalFile)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -186,7 +192,7 @@ fun CIImageView(
@Composable
expect fun SimpleAndAnimatedImageView(
uri: URI,
data: ByteArray,
imageBitmap: ImageBitmap,
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
@@ -44,14 +44,14 @@ fun CIMetaView(
modifier = Modifier.padding(start = 3.dp)
)
} else {
CIMetaText(chatItem.meta, timedMessagesTTL, metaColor, paleMetaColor)
CIMetaText(chatItem.meta, timedMessagesTTL, encrypted = chatItem.encryptedFile, metaColor, paleMetaColor)
}
}
}
@Composable
// changing this function requires updating reserveSpaceForMeta
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Color) {
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?, color: Color, paleColor: Color) {
if (meta.itemEdited) {
StatusIconText(painterResource(MR.images.ic_edit), color)
Spacer(Modifier.width(3.dp))
@@ -77,11 +77,15 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Col
StatusIconText(painterResource(MR.images.ic_circle_filled), Color.Transparent)
Spacer(Modifier.width(4.dp))
}
if (encrypted != null) {
StatusIconText(painterResource(if (encrypted) MR.images.ic_lock else MR.images.ic_lock_open_right), color)
Spacer(Modifier.width(4.dp))
}
Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
// the conditions in this function should match CIMetaText
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?): String {
val iconSpace = " "
var res = ""
if (meta.itemEdited) res += iconSpace
@@ -95,6 +99,9 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) {
res += iconSpace
}
if (encrypted != null) {
res += iconSpace
}
return res + meta.timestampText
}
@@ -166,7 +166,7 @@ fun DecryptionErrorItemFixButton(
Text(
buildAnnotatedString {
append(generalGetString(MR.strings.fix_connection))
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) }
withStyle(reserveTimestampStyle) { append(" ") } // for icon
},
color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
@@ -196,7 +196,7 @@ fun DecryptionErrorItem(
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null)) }
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null)) }
},
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
)
@@ -31,7 +31,7 @@ fun CIVideoView(
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
receiveFile: (Long, Boolean) -> Unit
) {
Box(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
@@ -54,7 +54,7 @@ fun CIVideoView(
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
receiveFileIfValidSize(file, receiveFile)
receiveFileIfValidSize(file, encrypted = false, receiveFile)
CIFileStatus.RcvAccepted ->
when (file.fileProtocol) {
FileProtocol.XFTP ->
@@ -80,7 +80,7 @@ fun CIVideoView(
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
}
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, encrypted = false, receiveFile) }
}
}
}
@@ -301,9 +301,9 @@ private fun fileSizeValid(file: CIFile?): Boolean {
return false
}
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
private fun receiveFileIfValidSize(file: CIFile, encrypted: Boolean, receiveFile: (Long, Boolean) -> Unit) {
if (fileSizeValid(file)) {
receiveFile(file.fileId)
receiveFile(file.fileId, encrypted)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -20,8 +20,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.getLoadedFilePath
import chat.simplex.common.platform.AudioPlayer
import chat.simplex.common.platform.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -37,21 +36,24 @@ fun CIVoiceView(
ci: ChatItem,
timedMessagesTTL: Int?,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
) {
Row(
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = if (hasText) 6.dp else 0.dp, end = if (hasText) 6.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (file != null) {
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(file) }
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) }
val f = file.fileSource?.filePath
val fileSource = remember(f, file.fileStatus) { getLoadedFileSource(file) }
var brokenAudio by rememberSaveable(f) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(f) { mutableStateOf(false) }
val progress = rememberSaveable(f) { mutableStateOf(0) }
val duration = rememberSaveable(f) { mutableStateOf(providedDurationSec * 1000) }
val play = {
AudioPlayer.play(filePath, audioPlaying, progress, duration, true)
brokenAudio = !audioPlaying.value
if (fileSource != null) {
AudioPlayer.play(fileSource, audioPlaying, progress, duration, true)
brokenAudio = !audioPlaying.value
}
}
val pause = {
AudioPlayer.pause(audioPlaying, progress)
@@ -66,7 +68,7 @@ fun CIVoiceView(
}
}
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) {
AudioPlayer.seekTo(it, progress, filePath)
AudioPlayer.seekTo(it, progress, fileSource?.filePath)
}
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile)
@@ -94,7 +96,7 @@ private fun VoiceLayout(
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
onProgressChanged: (Int) -> Unit,
) {
@Composable
@@ -248,7 +250,7 @@ private fun VoiceMsgIndicator(
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
) {
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
val strokeColor = MaterialTheme.colors.primary
@@ -268,7 +270,7 @@ private fun VoiceMsgIndicator(
}
} else {
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick)
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, chatController.appPrefs.privacyEncryptLocalFiles.get()) }, {}, longClick = longClick)
} else if (file?.fileStatus is CIFileStatus.RcvTransfer
|| file?.fileStatus is CIFileStatus.RcvAccepted
) {
@@ -48,7 +48,7 @@ fun ChatItemView(
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
@@ -191,9 +191,9 @@ fun ChatItemView(
}
val clipboard = LocalClipboardManager.current
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
val filePath = getLoadedFilePath(cItem.file)
val fileSource = getLoadedFileSource(cItem.file)
when {
filePath != null -> shareFile(cItem.text, filePath)
fileSource != null -> shareFile(cItem.text, fileSource)
else -> clipboard.shareText(cItem.content.text)
}
showMenu.value = false
@@ -566,7 +566,7 @@ fun PreviewChatItemView() {
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
acceptCall = { _ -> },
@@ -595,7 +595,7 @@ fun PreviewChatItemViewDeletedContent() {
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
acceptCall = { _ -> },
@@ -36,7 +36,7 @@ fun FramedItemView(
imageProvider: (() -> ImageGalleryProvider)? = null,
linkMode: SimplexLinkMode,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
onLinkLongClick: (link: String) -> Unit = {},
scrollToItem: (Long) -> Unit = {},
) {
@@ -226,7 +226,7 @@ fun FramedItemView(
} else {
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
CIImageView(image = mc.image, file = ci.file, ci.encryptLocalFile, metaColor = metaColor, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
@@ -123,8 +123,8 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
// LALAL
// https://github.com/JetBrains/compose-multiplatform/pull/2015/files#diff-841b3825c504584012e1d1c834d731bae794cce6acad425d81847c8bbbf239e0R24
if (media is ProviderMedia.Image) {
val (uri: URI, imageBitmap: ImageBitmap) = media
FullScreenImageView(modifier, uri, imageBitmap)
val (data: ByteArray, imageBitmap: ImageBitmap) = media
FullScreenImageView(modifier, data, imageBitmap)
} else if (media is ProviderMedia.Video) {
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
@@ -138,7 +138,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
}
@Composable
expect fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap)
expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap)
@Composable
private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) {
@@ -76,7 +76,7 @@ fun MarkdownText (
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
"\n"
} else if (meta != null) {
reserveSpaceForMeta(meta, chatTTL)
reserveSpaceForMeta(meta, chatTTL, null) // LALAL
} else {
" "
}
@@ -30,6 +30,7 @@ import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.res.MR
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.Clock
@@ -61,46 +62,8 @@ fun DatabaseEncryptionView(m: ChatModel) {
initialRandomDBPassphrase,
progressIndicator,
onConfirmEncrypt = {
progressIndicator.value = true
withApi {
try {
prefs.encryptionStartedAt.set(Clock.System.now())
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
prefs.encryptionStartedAt.set(null)
val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
when {
sqliteError is SQLiteError.ErrorNotADatabase -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.wrong_passphrase_title),
generalGetString(MR.strings.enter_correct_current_passphrase)
)
}
}
error != null -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database),
"failed to set storage encryption: ${error.responseType} ${error.details}"
)
}
}
else -> {
prefs.initialRandomDBPassphrase.set(false)
initialRandomDBPassphrase.value = false
if (useKeychain.value) {
DatabaseUtils.ksDatabasePassword.set(newKey.value)
}
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted))
}
}
}
} catch (e: Exception) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), e.stackTraceToString())
}
}
encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator)
}
}
)
@@ -143,17 +106,11 @@ fun DatabaseEncryptionLayout(
if (checked) {
setUseKeychain(true, useKeychain, prefs)
} else if (storedKey.value) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.remove_passphrase_from_keychain),
text = generalGetString(MR.strings.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
confirmText = generalGetString(MR.strings.remove_passphrase),
onConfirm = {
DatabaseUtils.ksDatabasePassword.remove()
setUseKeychain(false, useKeychain, prefs)
storedKey.value = false
},
destructive = true,
)
removePassphraseAlert {
DatabaseUtils.ksDatabasePassword.remove()
setUseKeychain(false, useKeychain, prefs)
storedKey.value = false
}
} else {
setUseKeychain(false, useKeychain, prefs)
}
@@ -217,37 +174,13 @@ fun DatabaseEncryptionLayout(
}
Column {
if (chatDbEncrypted == false) {
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely))
if (initialRandomDBPassphrase.value) {
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
} else {
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
}
} else {
SectionTextFooter(generalGetString(MR.strings.keychain_allows_to_receive_ntfs))
}
} else {
SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time))
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
}
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase)
}
SectionBottomSpacer()
}
}
fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.encrypt_database_question),
text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(MR.strings.encrypt_database),
onConfirm = onConfirm,
destructive = true,
)
}
expect fun encryptDatabaseSavedAlert(onConfirm: () -> Unit)
fun encryptDatabaseAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
@@ -259,15 +192,7 @@ fun encryptDatabaseAlert(onConfirm: () -> Unit) {
)
}
fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.change_database_passphrase_question),
text = generalGetString(MR.strings.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(MR.strings.update_database),
onConfirm = onConfirm,
destructive = false,
)
}
expect fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit)
fun changeDatabaseKeyAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
@@ -279,37 +204,25 @@ fun changeDatabaseKeyAlert(onConfirm: () -> Unit) {
)
}
expect fun removePassphraseAlert(onConfirm: () -> Unit)
@Composable
fun SavePassphraseSetting(
expect fun SavePassphraseSetting(
useKeychain: Boolean,
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
minHeight: Dp = TextFieldDefaults.MinHeight,
onCheckedChange: (Boolean) -> Unit,
) {
SectionItemView(minHeight = minHeight) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled),
stringResource(MR.strings.save_passphrase_in_keychain),
tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(MR.strings.save_passphrase_in_keychain),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
enabled = !initialRandomDBPassphrase && !progressIndicator
)
}
}
}
)
@Composable
expect fun DatabaseEncryptionFooter(
useKeychain: MutableState<Boolean>,
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
)
fun resetFormAfterEncryption(
m: ChatModel,
@@ -443,6 +356,62 @@ fun PassphraseField(
}
}
suspend fun encryptDatabase(
currentKey: MutableState<String>,
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
initialRandomDBPassphrase: MutableState<Boolean>,
useKeychain: MutableState<Boolean>,
storedKey: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>
): Boolean {
val m = ChatModel
val prefs = ChatController.appPrefs
progressIndicator.value = true
return try {
prefs.encryptionStartedAt.set(Clock.System.now())
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
prefs.encryptionStartedAt.set(null)
val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
when {
sqliteError is SQLiteError.ErrorNotADatabase -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.wrong_passphrase_title),
generalGetString(MR.strings.enter_correct_current_passphrase)
)
}
false
}
error != null -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database),
"failed to set storage encryption: ${error.responseType} ${error.details}"
)
}
false
}
else -> {
prefs.initialRandomDBPassphrase.set(false)
initialRandomDBPassphrase.value = false
if (useKeychain.value) {
DatabaseUtils.ksDatabasePassword.set(newKey.value)
}
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted))
}
true
}
}
} catch (e: Exception) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_encrypting_database), e.stackTraceToString())
}
false
}
}
// based on https://generatepasswords.org/how-to-calculate-entropy/
private fun passphraseEntropy(s: String): Double {
var hasDigits = false
@@ -12,6 +12,9 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.AppPreferences
@@ -252,6 +255,11 @@ private fun mtrErrorDescription(err: MTRError): String =
@Composable
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
delay(100L)
focusRequester.requestFocus()
}
PassphraseField(
text,
generalGetString(MR.strings.enter_passphrase),
@@ -259,7 +267,15 @@ private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onCli
keyboardActions = KeyboardActions(onDone = if (enabled) {
{ onClick?.invoke() }
} else null
)
),
modifier = Modifier.focusRequester(focusRequester).onPreviewKeyEvent {
if (onClick != null && it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
onClick()
true
} else {
false
}
}
)
}
@@ -39,7 +39,6 @@ fun DatabaseView(
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val progressIndicator = remember { mutableStateOf(false) }
val runChat = remember { m.chatRunning }
val prefs = m.controller.appPrefs
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
@@ -60,20 +59,19 @@ fun DatabaseView(
importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator)
}
}
LaunchedEffect(m.chatRunning) {
runChat.value = m.chatRunning.value ?: true
}
val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) }
Box(
Modifier.fillMaxSize(),
) {
DatabaseLayout(
progressIndicator.value,
runChat.value != false,
remember { m.chatRunning }.value != false,
m.chatDbChanged.value,
useKeychain.value,
m.chatDbEncrypted.value,
m.controller.appPrefs.storeDBPassphrase.state.value,
m.controller.appPrefs.initialRandomDBPassphrase,
m.controller.appPrefs.developerTools.state.value,
importArchiveLauncher,
chatArchiveName,
chatArchiveTime,
@@ -82,8 +80,8 @@ fun DatabaseView(
chatItemTTL,
m.currentUser.value,
m.users,
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
stopChatAlert = { stopChatAlert(m, runChat) },
startChat = { startChat(m, chatLastStart, m.chatDbChanged) },
stopChatAlert = { stopChatAlert(m) },
exportArchive = { exportArchive(m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
deleteAppFilesAndMedia = { deleteFilesAndMediaAlert(appFilesCountAndSize) },
@@ -122,7 +120,9 @@ fun DatabaseLayout(
chatDbChanged: Boolean,
useKeyChain: Boolean,
chatDbEncrypted: Boolean?,
passphraseSaved: Boolean,
initialRandomDBPassphrase: SharedPreference<Boolean>,
developerTools: Boolean,
importArchiveLauncher: FileChooserLauncher,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
@@ -178,13 +178,21 @@ fun DatabaseLayout(
SectionView(stringResource(MR.strings.chat_database_section)) {
val unencrypted = chatDbEncrypted == false
SettingsActionItem(
if (unencrypted) painterResource(MR.images.ic_lock_open) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
else painterResource(MR.images.ic_lock),
stringResource(MR.strings.database_passphrase),
click = showSettingsModal() { DatabaseEncryptionView(it) },
iconColor = if (unencrypted) WarningOrange else MaterialTheme.colors.secondary,
iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary,
disabled = operationsDisabled
)
if (appPlatform.isDesktop && developerTools) {
SettingsActionItem(
painterResource(MR.images.ic_folder_open),
stringResource(MR.strings.open_database_folder),
::desktopOpenDatabaseDir,
disabled = operationsDisabled
)
}
SettingsActionItem(
painterResource(MR.images.ic_ios_share),
stringResource(MR.strings.export_database),
@@ -327,7 +335,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
return stringResource(if (chatArchiveTime < chatLastStart) MR.strings.old_database_archive else MR.strings.new_database_archive)
}
private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
private fun startChat(m: ChatModel, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
withApi {
try {
if (chatDbChanged.value) {
@@ -344,7 +352,6 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastSta
return@withApi
} else {
m.controller.apiStartChat()
runChat.value = true
m.chatRunning.value = true
}
val ts = Clock.System.now()
@@ -352,19 +359,19 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastSta
chatLastStart.value = ts
platform.androidChatStartedAfterBeingOff()
} catch (e: Error) {
runChat.value = false
m.chatRunning.value = false
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.toString())
}
}
}
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean?>) {
private fun stopChatAlert(m: ChatModel) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.stop_chat_question),
text = generalGetString(MR.strings.stop_chat_to_export_import_or_delete_chat_database),
confirmText = generalGetString(MR.strings.stop_chat_confirmation),
onConfirm = { authStopChat(m, runChat) },
onDismiss = { runChat.value = true }
onConfirm = { authStopChat(m) },
onDismiss = { m.chatRunning.value = true }
)
}
@@ -375,7 +382,7 @@ private fun exportProhibitedAlert() {
)
}
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>) {
private fun authStopChat(m: ChatModel) {
if (m.controller.appPrefs.performLA.get()) {
authenticate(
generalGetString(MR.strings.auth_stop_chat),
@@ -383,30 +390,29 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>) {
completed = { laResult ->
when (laResult) {
LAResult.Success, is LAResult.Unavailable -> {
stopChat(m, runChat)
stopChat(m)
}
is LAResult.Error -> {
runChat.value = true
m.chatRunning.value = true
}
is LAResult.Failed -> {
runChat.value = true
m.chatRunning.value = true
}
}
}
)
} else {
stopChat(m, runChat)
stopChat(m)
}
}
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean?>) {
private fun stopChat(m: ChatModel) {
withApi {
try {
runChat.value = false
stopChatAsync(m)
platform.androidChatStopped()
} catch (e: Error) {
runChat.value = true
m.chatRunning.value = true
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_stopping_chat), e.toString())
}
}
@@ -657,7 +663,9 @@ fun PreviewDatabaseLayout() {
chatDbChanged = false,
useKeyChain = false,
chatDbEncrypted = false,
passphraseSaved = false,
initialRandomDBPassphrase = SharedPreference({ true }, {}),
developerTools = true,
importArchiveLauncher = rememberFileChooserLauncher(true) {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
@@ -101,6 +101,10 @@ class AlertManager {
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF),
horizontalArrangement = Arrangement.SpaceBetween
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
TextButton(onClick = {
onDismiss?.invoke()
hideAlert()
@@ -108,7 +112,7 @@ class AlertManager {
TextButton(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
}, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
}
},
shape = RoundedCornerShape(corner = CornerSize(25.dp))
@@ -54,6 +54,12 @@ object DatabaseUtils {
} else {
dbKey = ksDatabasePassword.get() ?: ""
}
} else if (appPlatform.isDesktop && !hasDatabase(dataDir.absolutePath)) {
// In case of database was deleted by hand
dbKey = randomDatabasePassword()
ksDatabasePassword.set(dbKey)
appPreferences.initialRandomDBPassphrase.set(true)
appPreferences.storeDBPassphrase.set(true)
}
return dbKey
}
@@ -32,7 +32,7 @@ import kotlinx.coroutines.launch
@Composable
fun DefaultBasicTextField(
modifier: Modifier,
initialValue: String,
state: MutableState<TextFieldValue>,
placeholder: (@Composable () -> Unit)? = null,
leadingIcon: (@Composable () -> Unit)? = null,
focus: Boolean = false,
@@ -41,11 +41,8 @@ fun DefaultBasicTextField(
selectTextOnFocus: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions: KeyboardActions = KeyboardActions(),
onValueChange: (String) -> Unit,
onValueChange: (TextFieldValue) -> Unit,
) {
val state = remember {
mutableStateOf(TextFieldValue(initialValue))
}
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
@@ -83,8 +80,7 @@ fun DefaultBasicTextField(
minHeight = TextFieldDefaults.MinHeight
),
onValueChange = {
state.value = it
onValueChange(it.text)
onValueChange(it)
},
cursorBrush = SolidColor(colors.cursorColor(false).value),
visualTransformation = VisualTransformation.None,
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
@@ -66,11 +67,13 @@ fun SimpleButton(
fun SimpleButtonIconEnded(
text: String,
icon: Painter,
style: TextStyle = MaterialTheme.typography.caption,
color: Color = MaterialTheme.colors.primary,
disabled: Boolean = false,
click: () -> Unit
) {
SimpleButtonFrame(click) {
Text(text, style = MaterialTheme.typography.caption, color = color)
SimpleButtonFrame(click, disabled = disabled) {
Text(text, style = style, color = color)
Icon(
icon, text, tint = color,
modifier = Modifier.padding(start = 8.dp)
@@ -67,7 +67,7 @@ const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB
expect fun getAppFileUri(fileName: String): URI
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
expect fun getLoadedImage(file: CIFile?): ImageBitmap?
expect fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
expect fun getFileName(uri: URI): String?
@@ -77,6 +77,8 @@ expect fun getFileSize(uri: URI): Long?
expect fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean = true): ImageBitmap?
expect fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap?
expect fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean = true): Any?
fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverrides? {
@@ -95,29 +97,34 @@ fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverri
return null
}
fun saveImage(uri: URI): String? {
fun saveImage(uri: URI, encrypted: Boolean): CryptoFile? {
val bitmap = getBitmapFromUri(uri) ?: return null
return saveImage(bitmap)
return saveImage(bitmap, encrypted)
}
fun saveImage(image: ImageBitmap): String? {
fun saveImage(image: ImageBitmap, encrypted: Boolean): CryptoFile? {
return try {
val ext = if (image.hasAlpha()) "png" else "jpg"
val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE)
val fileToSave = generateNewFileName("IMG", ext)
val file = File(getAppFilePath(fileToSave))
val output = FileOutputStream(file)
dataResized.writeTo(output)
output.flush()
output.close()
fileToSave
val destFileName = generateNewFileName("IMG", ext)
val destFile = File(getAppFilePath(destFileName))
if (encrypted) {
val args = writeCryptoFile(destFile.absolutePath, dataResized.toByteArray())
CryptoFile(destFileName, args)
} else {
val output = FileOutputStream(destFile)
dataResized.writeTo(output)
output.flush()
output.close()
CryptoFile.plain(destFileName)
}
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveImage error: ${e.stackTraceToString()}")
null
}
}
fun saveAnimImage(uri: URI): String? {
fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? {
return try {
val filename = getFileName(uri)?.lowercase()
var ext = when {
@@ -127,15 +134,15 @@ fun saveAnimImage(uri: URI): String? {
}
// Just in case the image has a strange extension
if (ext.length < 3 || ext.length > 4) ext = "gif"
val fileToSave = generateNewFileName("IMG", ext)
val file = File(getAppFilePath(fileToSave))
val output = FileOutputStream(file)
uri.inputStream().use { input ->
output.use { output ->
input?.copyTo(output)
}
val destFileName = generateNewFileName("IMG", ext)
val destFile = File(getAppFilePath(destFileName))
if (encrypted) {
val args = writeCryptoFile(destFile.absolutePath, uri.inputStream()?.readAllBytes() ?: return null)
CryptoFile(destFileName, args)
} else {
Files.copy(uri.inputStream(), destFile.toPath())
CryptoFile.plain(destFileName)
}
fileToSave
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveAnimImage error: ${e.message}")
null
@@ -144,25 +151,44 @@ fun saveAnimImage(uri: URI): String? {
expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File?
fun saveFileFromUri(uri: URI): String? {
fun saveFileFromUri(uri: URI, encrypted: Boolean): CryptoFile? {
return try {
val inputStream = uri.inputStream()
val fileToSave = getFileName(uri)
if (inputStream != null && fileToSave != null) {
return if (inputStream != null && fileToSave != null) {
val destFileName = uniqueCombine(fileToSave)
val destFile = File(getAppFilePath(destFileName))
Files.copy(inputStream, destFile.toPath())
destFileName
if (encrypted) {
createTmpFileAndDelete { tmpFile ->
Files.copy(inputStream, tmpFile.toPath())
val args = encryptCryptoFile(tmpFile.absolutePath, destFile.absolutePath)
CryptoFile(destFileName, args)
}
} else {
Files.copy(inputStream, destFile.toPath())
CryptoFile.plain(destFileName)
}
} else {
Log.e(TAG, "Util.kt saveFileFromUri null inputStream")
null
}
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveFileFromUri error: ${e.message}")
Log.e(TAG, "Util.kt saveFileFromUri error: ${e.stackTraceToString()}")
null
}
}
fun <T> createTmpFileAndDelete(onCreated: (File) -> T): T {
val tmpFile = File(tmpDir, UUID.randomUUID().toString())
tmpFile.deleteOnExit()
ChatModel.filesToDelete.add(tmpFile)
try {
return onCreated(tmpFile)
} finally {
tmpFile.delete()
}
}
fun generateNewFileName(prefix: String, ext: String): String {
val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("GMT")
@@ -263,6 +289,17 @@ fun blendARGB(
return Color(r, g, b, a)
}
fun InputStream.toByteArray(): ByteArray =
ByteArrayOutputStream().use { output ->
val b = ByteArray(4096)
var n = read(b)
while (n != -1) {
output.write(b, 0, n);
n = read(b)
}
return output.toByteArray()
}
expect fun ByteArray.toBase64StringForPassphrase(): String
// Android's default implementation that was used before multiplatform, adds non-needed characters at the end of string
@@ -66,7 +66,6 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: (
val createdUser = m.controller.apiCreateActiveUser(profile, pastTimestamp = true)
m.currentUser.value = createdUser
m.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete)
m.onboardingStage.value = OnboardingStage.OnboardingComplete
if (createdUser != null) {
m.controller.startChat(createdUser)
}
@@ -126,7 +126,7 @@ private fun ContactConnectionInfoLayout(
)
if (contactConnection.groupLinkId == null) {
LocalAliasEditor(contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged)
LocalAliasEditor(contactConnection.id, contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged)
}
SectionView {
@@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.stringResource
import boofcv.alg.drawing.FiducialImageEngine
import boofcv.alg.fiducial.qrcode.*
import chat.simplex.common.model.CryptoFile
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.*
@@ -45,7 +46,7 @@ fun QRCode(
.let { if (withLogo) it.addLogo() else it }
val file = saveTempImageUncompressed(image, false)
if (file != null) {
shareFile("", file.absolutePath)
shareFile("", CryptoFile.plain(file.absolutePath))
}
}
}
@@ -14,8 +14,7 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.UserContactLinkRec
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -29,6 +28,10 @@ fun CreateSimpleXAddress(m: ChatModel) {
val clipboard = LocalClipboardManager.current
val uriHandler = LocalUriHandler.current
LaunchedEffect(Unit) {
prepareChatBeforeAddressCreation()
}
CreateSimpleXAddressLayout(
userAddress.value,
share = { address: String -> clipboard.shareText(address) },
@@ -63,7 +66,6 @@ fun CreateSimpleXAddress(m: ChatModel) {
OnboardingStage.OnboardingComplete
}
m.controller.appPrefs.onboardingStage.set(next)
m.onboardingStage.value = next
},
)
@@ -172,3 +174,19 @@ private fun ProgressIndicator() {
)
}
}
private fun prepareChatBeforeAddressCreation() {
if (chatModel.users.isNotEmpty()) return
withApi {
val user = chatModel.controller.apiGetActiveUser() ?: return@withApi
chatModel.currentUser.value = user
if (chatModel.users.isEmpty()) {
chatModel.controller.startChat(user)
} else {
val users = chatModel.controller.listUsers()
chatModel.users.clear()
chatModel.users.addAll(users)
chatModel.controller.getUserChatData()
}
}
}
@@ -13,8 +13,7 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.User
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
@@ -22,7 +21,7 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
@Composable
fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = null) {
fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>? = null) {
Column(Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
@@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
enum class OnboardingStage {
Step1_SimpleXInfo,
Step2_CreateProfile,
Step2_5_SetupDatabasePassphrase,
Step3_CreateSimpleXAddress,
Step4_SetNotificationsMode,
OnboardingComplete
@@ -41,7 +41,7 @@ fun SetNotificationsMode(m: ChatModel) {
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), contentAlignment = Alignment.Center) {
OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage, false) {
OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, false) {
changeNotificationsMode(currentMode.value, m)
}
}
@@ -0,0 +1,233 @@
package chat.simplex.common.views.onboarding
import SectionBottomSpacer
import SectionItemView
import SectionItemViewSpaceBetween
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.database.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@Composable
fun SetupDatabasePassphrase(m: ChatModel) {
val progressIndicator = remember { mutableStateOf(false) }
val prefs = m.controller.appPrefs
val saveInPreferences = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
// 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("") }
val confirmNewKey = rememberSaveable { mutableStateOf("") }
fun nextStep() {
m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress)
}
SetupDatabasePassphraseLayout(
currentKey,
newKey,
confirmNewKey,
progressIndicator,
onConfirmEncrypt = {
withApi {
if (m.chatRunning.value == true) {
// Stop chat if it's started before doing anything
stopChatAsync(m)
}
prefs.storeDBPassphrase.set(false)
val newKeyValue = newKey.value
val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator)
if (success) {
startChat(newKeyValue)
nextStep()
} else {
// Rollback in case of it is finished with error in order to allow to repeat the process again
prefs.storeDBPassphrase.set(true)
}
}
},
nextStep = ::nextStep,
)
if (progressIndicator.value) {
ProgressIndicator()
}
DisposableEffect(Unit) {
onDispose {
if (m.chatRunning.value != true) {
withBGApi {
val user = chatController.apiGetActiveUser()
if (user != null) {
m.controller.startChat(user)
}
}
}
}
}
}
@Composable
private fun SetupDatabasePassphraseLayout(
currentKey: MutableState<String>,
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
progressIndicator: MutableState<Boolean>,
onConfirmEncrypt: () -> Unit,
nextStep: () -> Unit,
) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = DEFAULT_PADDING),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarTitle(stringResource(MR.strings.setup_database_passphrase))
Spacer(Modifier.weight(1f))
Column(Modifier.width(600.dp)) {
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
delay(100L)
focusRequester.requestFocus()
}
PassphraseField(
newKey,
generalGetString(MR.strings.new_passphrase),
modifier = Modifier
.padding(horizontal = DEFAULT_PADDING)
.focusRequester(focusRequester)
.onPreviewKeyEvent {
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
focusManager.moveFocus(FocusDirection.Down)
true
} else {
false
}
},
showStrength = true,
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
)
val onClickUpdate = {
// Don't do things concurrently. Shouldn't be here concurrently, just in case
if (!progressIndicator.value) {
encryptDatabaseAlert(onConfirmEncrypt)
}
}
val disabled = currentKey.value == newKey.value ||
newKey.value != confirmNewKey.value ||
newKey.value.isEmpty() ||
!validKey(currentKey.value) ||
!validKey(newKey.value) ||
progressIndicator.value
PassphraseField(
confirmNewKey,
generalGetString(MR.strings.confirm_new_passphrase),
modifier = Modifier
.padding(horizontal = DEFAULT_PADDING)
.onPreviewKeyEvent {
if (!disabled && it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
onClickUpdate()
true
} else {
false
}
},
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
keyboardActions = KeyboardActions(onDone = {
if (!disabled) onClickUpdate()
defaultKeyboardAction(ImeAction.Done)
}),
)
Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) {
SetPassphraseButton(disabled, onClickUpdate)
}
Column {
SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time))
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
}
}
Spacer(Modifier.weight(1f))
SkipButton(progressIndicator.value, nextStep)
SectionBottomSpacer()
}
}
@Composable
private fun SetPassphraseButton(disabled: Boolean, onClick: () -> Unit) {
SimpleButtonIconEnded(
stringResource(MR.strings.set_database_passphrase),
painterResource(MR.images.ic_check),
style = MaterialTheme.typography.h2,
color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
disabled = disabled,
click = onClick
)
}
@Composable
private fun SkipButton(disabled: Boolean, onClick: () -> Unit) {
SimpleButtonIconEnded(stringResource(MR.strings.use_random_passphrase), painterResource(MR.images.ic_chevron_right), color =
if (disabled) MaterialTheme.colors.secondary else WarningOrange, disabled = disabled, click = onClick)
Text(
stringResource(MR.strings.you_can_change_it_later),
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING * 3),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.secondary,
textAlign = TextAlign.Center,
)
}
@Composable
private fun ProgressIndicator() {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 3.dp
)
}
}
private suspend fun startChat(key: String?) {
val m = ChatModel
initChatController(key)
m.chatDbChanged.value = false
m.chatRunning.value = true
}
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
@@ -25,7 +24,7 @@ import dev.icerock.moko.resources.StringResource
fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
SimpleXInfoLayout(
user = chatModel.currentUser.value,
onboardingStage = if (onboarding) chatModel.onboardingStage else null,
onboardingStage = if (onboarding) chatModel.controller.appPrefs.onboardingStage else null,
showModal = { modalView -> { if (onboarding) ModalManager.fullscreen.showModal { modalView(chatModel) } else ModalManager.start.showModal { modalView(chatModel) } } },
)
}
@@ -33,7 +32,7 @@ fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) {
@Composable
fun SimpleXInfoLayout(
user: User?,
onboardingStage: MutableState<OnboardingStage?>?,
onboardingStage: SharedPreference<OnboardingStage>?,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
) {
Column(
@@ -100,11 +99,11 @@ private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResour
}
@Composable
fun OnboardingActionButton(user: User?, onboardingStage: MutableState<OnboardingStage?>, onclick: (() -> Unit)? = null) {
fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference<OnboardingStage>, onclick: (() -> Unit)? = null) {
if (user == null) {
OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, onboardingStage, true, onclick)
OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick)
} else {
OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onboardingStage, true, onclick)
OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick)
}
}
@@ -112,7 +111,6 @@ fun OnboardingActionButton(user: User?, onboardingStage: MutableState<Onboarding
fun OnboardingActionButton(
labelId: StringResource,
onboarding: OnboardingStage?,
onboardingStage: MutableState<OnboardingStage?>,
border: Boolean,
onclick: (() -> Unit)?
) {
@@ -129,7 +127,6 @@ fun OnboardingActionButton(
SimpleButtonFrame(click = {
onclick?.invoke()
onboardingStage.value = onboarding
if (onboarding != null) {
ChatController.appPrefs.onboardingStage.set(onboarding)
}
@@ -201,12 +201,15 @@ object AppearanceScope {
val supportedLanguages = mapOf(
"system" to generalGetString(MR.strings.language_system),
"en" to "English",
"ar" to "العربية",
"bg" to "Български",
"cs" to "Čeština",
"de" to "Deutsch",
"es" to "Español",
"fi" to "Suomi",
"fr" to "Français",
"it" to "Italiano",
"iw" to "עִברִית",
"ja" to "日本語",
"nl" to "Nederlands",
"pl" to "Polski",
@@ -180,7 +180,13 @@ fun NetworkAndServersView(
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
}
if (networkUseSocksProxy.value) {
SectionCustomFooter { Text(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported)) }
SectionCustomFooter {
Column {
Text(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported))
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
Text(annotatedStringResource(MR.strings.socks_proxy_setting_limitations))
}
}
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
} else {
Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp))
@@ -64,6 +64,7 @@ fun PrivacySettingsView(
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_chats)) {
SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles)
SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
SettingsPreferenceItem(
@@ -43,6 +43,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerSt
profile = user.profile,
stopped,
chatModel.chatDbEncrypted.value == true,
remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value,
remember { chatModel.controller.appPrefs.notificationsMode.state },
user.displayName,
setPerformLA = setPerformLA,
@@ -115,6 +116,7 @@ fun SettingsLayout(
profile: LocalProfile,
stopped: Boolean,
encrypted: Boolean,
passphraseSaved: Boolean,
notificationsMode: State<NotificationsMode>,
userDisplayName: String,
setPerformLA: (Boolean) -> Unit,
@@ -162,7 +164,7 @@ fun SettingsLayout(
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true)
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
SectionDividerSpaced()
@@ -207,7 +209,7 @@ expect fun SettingsSectionApp(
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit
)
@Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) {
@Composable private fun DatabaseItem(encrypted: Boolean, saved: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) {
SectionItemViewWithIcon(openDatabaseView) {
Row(
Modifier.fillMaxWidth(),
@@ -217,7 +219,7 @@ expect fun SettingsSectionApp(
Icon(
painterResource(MR.images.ic_database),
contentDescription = stringResource(MR.strings.database_passphrase_and_export),
tint = if (encrypted) MaterialTheme.colors.secondary else WarningOrange,
tint = if (encrypted && (appPlatform.isAndroid || !saved)) MaterialTheme.colors.secondary else WarningOrange,
)
TextIconSpaced(true)
Text(stringResource(MR.strings.database_passphrase_and_export))
@@ -393,9 +395,13 @@ fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (
val padding = with(LocalDensity.current) { 6.sp.toDp() }
Text(text, Modifier.weight(1f).padding(vertical = padding), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING))
}
Row(Modifier.widthIn(max = (windowWidth() - DEFAULT_PADDING * 2) / 2)) {
content()
Row(Modifier.widthIn(max = (windowWidth() - DEFAULT_PADDING * 2) / 2)) {
content()
}
} else {
Row {
content()
}
}
}
}
@@ -469,6 +475,7 @@ fun PreviewSettingsLayout() {
profile = LocalProfile.sampleData,
stopped = false,
encrypted = false,
passphraseSaved = false,
notificationsMode = remember { mutableStateOf(NotificationsMode.OFF) },
userDisplayName = "Alice",
setPerformLA = { _ -> },
@@ -164,10 +164,9 @@ private fun UserProfilesLayout(
) {
if (profileHidden.value) {
SectionView {
SettingsActionItem(painterResource(MR.images.ic_lock_open), stringResource(MR.strings.enter_password_to_show), click = {
SettingsActionItem(painterResource(MR.images.ic_lock_open_right), stringResource(MR.strings.enter_password_to_show), click = {
profileHidden.value = false
}
)
})
}
SectionSpacer()
}
@@ -223,7 +222,7 @@ private fun UserView(
Box(Modifier.padding(horizontal = DEFAULT_PADDING)) {
DefaultDropdownMenu(showMenu) {
if (user.hidden) {
ItemAction(stringResource(MR.strings.user_unhide), painterResource(MR.images.ic_lock_open), onClick = {
ItemAction(stringResource(MR.strings.user_unhide), painterResource(MR.images.ic_lock_open_right), onClick = {
showMenu.value = false
unhideUser(user)
})
@@ -24,7 +24,7 @@
<string name="member_role_will_be_changed_with_notification">سيتم تغيير الدور إلى \"%s\". سيتم إبلاغ كل فرد في المجموعة.</string>
<string name="member_role_will_be_changed_with_invitation">سيتم تغيير الدور إلى \"%s\". سيتلقى العضو دعوة جديدة.</string>
<string name="smp_servers_per_user">خوادم الاتصالات الجديدة لملف تعريف الدردشة الحالي الخاص بك</string>
<string name="switch_receiving_address_desc">هذه الميزة تجريبية! ستعمل فقط إذا كان لدى العميل الآخر الإصدار 4.2 مثبتًا. يجب أن ترى الرسالة في المحادثة بمجرد اكتمال تغيير العنوان - يرجى التحقق من أنه لا يزال بإمكانك تلقي الرسائل من جهة الاتصال هذه (أو عضو المجموعة).</string>
<string name="switch_receiving_address_desc">سيتم تغيير عنوان الاستلام إلى خادم مختلف. سيتم إكمال تغيير العنوان بعد اتصال المرسل بالإنترنت.</string>
<string name="this_link_is_not_a_valid_connection_link">هذا الارتباط ليس ارتباط اتصال صالح!</string>
<string name="allow_verb">يسمح</string>
<string name="smp_servers_preset_add">أضف خوادم محددة مسبقًا</string>
@@ -615,7 +615,7 @@
<string name="network_use_onion_hosts_required">Required</string>
<string name="network_use_onion_hosts_prefer_desc">Onion hosts will be used when available.</string>
<string name="network_use_onion_hosts_no_desc">Onion hosts will not be used.</string>
<string name="network_use_onion_hosts_required_desc">Onion hosts will be required for connection.</string>
<string name="network_use_onion_hosts_required_desc">Onion hosts will be required for connection.\nPlease note: you will not be able to connect to the servers without .onion address.</string>
<string name="network_use_onion_hosts_prefer_desc_in_alert">Onion hosts will be used when available.</string>
<string name="network_use_onion_hosts_no_desc_in_alert">Onion hosts will not be used.</string>
<string name="network_use_onion_hosts_required_desc_in_alert">Onion hosts will be required for connection.</string>
@@ -626,6 +626,7 @@
<string name="network_session_mode_entity_description"><![CDATA[A separate TCP connection (and SOCKS credential) will be used <b>for each contact and group member</b>.\n<b>Please note</b>: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.]]></string>
<string name="update_network_session_mode_question">Update transport isolation mode?</string>
<string name="disable_onion_hosts_when_not_supported"><![CDATA[Set <i>Use .onion hosts</i> to No if SOCKS proxy does not support them.]]></string>
<string name="socks_proxy_setting_limitations"><![CDATA[<b>Please note</b>: message and file relays are connected via SOCKS proxy. Calls and sending link previews use direct connection.]]></string>
<string name="appearance_settings">Appearance</string>
<string name="customize_theme_title">Customize theme</string>
<string name="theme_colors_section_title">THEME COLORS</string>
@@ -769,6 +770,11 @@
<string name="onboarding_notifications_mode_periodic_desc"><![CDATA[<b>Good for battery</b>. Background service checks messages every 10 minutes. You may miss calls or urgent messages.]]></string>
<string name="onboarding_notifications_mode_service_desc"><![CDATA[<b>Uses more battery</b>! Background service always runs – notifications are shown as soon as messages are available.]]></string>
<!-- SetupDatabasePassphrase.kt -->
<string name="setup_database_passphrase">Setup database passphrase</string>
<string name="you_can_change_it_later">Random passphrase is stored in settings as plaintext.\nYou can change it later.</string>
<string name="use_random_passphrase">Use random passphrase</string>
<!-- MakeConnection -->
<string name="paste_the_link_you_received">Paste received link</string>
@@ -850,6 +856,7 @@
<string name="privacy_and_security">Privacy &amp; security</string>
<string name="your_privacy">Your privacy</string>
<string name="protect_app_screen">Protect app screen</string>
<string name="encrypt_local_files">Encrypt local files</string>
<string name="auto_accept_images">Auto-accept images</string>
<string name="send_link_previews">Send link previews</string>
<string name="privacy_show_last_messages">Show last messages</string>
@@ -939,6 +946,7 @@
<string name="import_database">Import database</string>
<string name="new_database_archive">New database archive</string>
<string name="old_database_archive">Old database archive</string>
<string name="open_database_folder">Open database folder</string>
<string name="delete_database">Delete database</string>
<string name="error_starting_chat">Error starting chat</string>
<string name="stop_chat_question">Stop chat?</string>
@@ -984,9 +992,11 @@
<!-- 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="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>
<string name="remove_passphrase_from_settings">Remove passphrase from settings?</string>
<string name="notifications_will_be_hidden">Notifications will be delivered only until the app stops!</string>
<string name="remove_passphrase">Remove</string>
<string name="encrypt_database">Encrypt</string>
@@ -995,18 +1005,23 @@
<string name="new_passphrase">New passphrase…</string>
<string name="confirm_new_passphrase">Confirm new passphrase…</string>
<string name="update_database_passphrase">Update database passphrase</string>
<string name="set_database_passphrase">Set database 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="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>
<string name="keychain_allows_to_receive_ntfs">Android Keystore will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving notifications.</string>
<string name="passphrase_will_be_saved_in_settings">The passphrase will be stored in settings as plaintext after you change it or restart the app.</string>
<string name="you_have_to_enter_passphrase_every_time">You have to enter passphrase every time the app starts - it is not stored on the device.</string>
<string name="encrypt_database_question">Encrypt database?</string>
<string name="change_database_passphrase_question">Change database passphrase?</string>
<string name="database_will_be_encrypted">Database will be encrypted.</string>
<string name="database_will_be_encrypted_and_passphrase_stored">Database will be encrypted and the passphrase stored in the Keystore.</string>
<string name="database_will_be_encrypted_and_passphrase_stored_in_settings">Database will be encrypted and the passphrase stored in settings.</string>
<string name="database_encryption_will_be_updated">Database encryption passphrase will be updated and stored in the Keystore.</string>
<string name="database_encryption_will_be_updated_in_settings">Database encryption passphrase will be updated and stored in settings.</string>
<string name="database_passphrase_will_be_updated">Database encryption passphrase will be updated.</string>
<string name="store_passphrase_securely">Please store passphrase securely, you will NOT be able to change it if you lose it.</string>
<string name="store_passphrase_securely_without_recover">Please store passphrase securely, you will NOT be able to access chat if you lose it.</string>
@@ -1337,7 +1337,7 @@
<string name="v5_2_message_delivery_receipts_descr">Druhé zaškrtnutí jsme přehlédli! ✅</string>
<string name="switch_receiving_address_desc">Přijímací adresa bude změněna na jiný server. Změna adresy bude dokončena po připojení odesílatele.</string>
<string name="choose_file_title">Vybrat soubor</string>
<string name="connect_via_link_incognito">Připojit se inkognito</string>
<string name="connect_via_link_incognito">Spojit se inkognito</string>
<string name="turn_off_battery_optimization_button">Povolit</string>
<string name="disable_notifications_button">Vypnout upozornění</string>
<string name="turn_off_system_restriction_button">Otevřít nastavení aplikace</string>
@@ -1355,4 +1355,25 @@
<string name="send_receipts_disabled">vypnut</string>
<string name="send_receipts_disabled_alert_title">Receipts jsou zakázány</string>
<string name="in_developing_title">Již brzy!</string>
<string name="connect_use_current_profile">Použít aktuální profil</string>
<string name="connect_use_new_incognito_profile">Použít nový incognito profil</string>
<string name="system_restricted_background_desc">SimpleX nemůže běžet na pozadí. Pouze při spuštěné aplikaci obdržíte upozornění.</string>
<string name="system_restricted_background_warn"><![CDATA[Chcete-li povolit oznámení, vyberte prosím <b>Baterii</b> / <b>bez omezení</b> v nastavení aplikace.]]></string>
<string name="system_restricted_background_in_call_desc">Aplikace může být uzavřena po 1 minutě na pozadí.</string>
<string name="system_restricted_background_in_call_warn"><![CDATA[Chcete-li volat na pozadí, vyberte prosím <b>Baterii</b> / <b>bez omezení</b> v nastavení aplikace.]]></string>
<string name="connect__your_profile_will_be_shared">Váš profil %1$s bude sdílen.</string>
<string name="connect_via_member_address_alert_desc">Požadavek na připojení bude zaslán tomuto členu skupiny.</string>
<string name="delivery">Doručenka</string>
<string name="receipts_groups_title_disable">Zakázat doručenky pro skupiny\?</string>
<string name="receipts_groups_title_enable">Povolit doručenky pro skupiny\?</string>
<string name="receipts_groups_override_enabled">Odeslání doručenek je povoleno pro %d skupiny</string>
<string name="receipts_section_groups">Malé skupiny (max. 20)</string>
<string name="recipient_colon_delivery_status">%s: %s</string>
<string name="rcv_group_event_2_members_connected">%s a %s připojen</string>
<string name="rcv_group_event_3_members_connected">%s, %s a %s připojeni</string>
<string name="rcv_group_event_n_members_connected">%s, %s a %d dalších členů připojeno</string>
<string name="privacy_message_draft">Rozepsáno</string>
<string name="privacy_show_last_messages">Zobrazit poslední zprávy</string>
<string name="send_receipts_disabled_alert_msg">Tato skupina má více než %1$d členů, doručenky nejsou odeslány.</string>
<string name="in_developing_desc">Tato funkce zatím není podporována. Vyzkoušejte další vydání.</string>
</resources>
@@ -146,7 +146,7 @@
<string name="smp_server_test_disconnect">Desconectar</string>
<string name="notification_preview_mode_contact">Contacto</string>
<string name="copy_verb">Copiar</string>
<string name="create_your_profile">Crear tu perfil</string>
<string name="create_your_profile">Crea tu perfil</string>
<string name="always_use_relay">Usar siempre retransmisor</string>
<string name="set_password_to_export_desc">La base de datos está cifrada con una contraseña aleatoria. Cámbiala antes de exportar.</string>
<string name="total_files_count_and_size">%d archivo(s) con tamaño total de %s</string>
@@ -192,7 +192,7 @@
<string name="delete_address__question">¿Eliminar la dirección\?</string>
<string name="display_name__field">Nombre mostrado:</string>
<string name="callstate_connecting">conectando…</string>
<string name="decentralized">Descentralizado</string>
<string name="decentralized">Descentralizada</string>
<string name="database_will_be_encrypted">La base de datos será cifrada.</string>
<string name="delete_chat_archive_question">¿Eliminar archivo del chat\?</string>
<string name="create_group_link">Crear enlace de grupo</string>
@@ -485,7 +485,7 @@
<string name="mark_unread">Marcar como no leído</string>
<string name="invalid_QR_code">Código QR inválido</string>
<string name="incorrect_code">¡Código de seguridad incorrecto!</string>
<string name="markdown_in_messages">Sintaxis markdown en los mensajes</string>
<string name="markdown_in_messages">Sintaxis Markdown</string>
<string name="network_use_onion_hosts_no">No</string>
<string name="callstatus_missed">llamada perdida</string>
<string name="import_database_confirmation">Importar</string>
@@ -717,7 +717,7 @@
<string name="la_notice_title_simplex_lock">Bloqueo SimpleX</string>
<string name="auth_unlock">Desbloquear</string>
<string name="this_text_is_available_in_settings">Este texto está disponible en Configuración</string>
<string name="switch_receiving_address_desc">La dirección de recepción se cambiará. El cambio se completará cuando el remitente esté en línea.</string>
<string name="switch_receiving_address_desc">La dirección de recepción pasará a otro servidor. El cambio se completará cuando el remitente esté en línea.</string>
<string name="chat_lock">Bloqueo SimpleX</string>
<string name="using_simplex_chat_servers">Usando servidores SimpleX Chat.</string>
<string name="network_session_mode_transport_isolation">Aislamiento de transporte</string>
@@ -749,11 +749,11 @@
<string name="share_invitation_link">Compartir enlace de un uso</string>
<string name="update_network_session_mode_question">¿Actualizar el modo de aislamiento de transporte\?</string>
<string name="icon_descr_speaker_on">Altavoz activado</string>
<string name="stop_chat_to_enable_database_actions">Para habilitar las acciones sobre la base de datos, previamente debes detener Chat</string>
<string name="stop_chat_to_enable_database_actions">Detén SimpleX para habilitar las acciones sobre la base de datos.</string>
<string name="connection_you_accepted_will_be_cancelled">¡La conexión que has aceptado se cancelará!</string>
<string name="database_initialization_error_desc">La base de datos no funciona correctamente. Pulsa para saber más</string>
<string name="moderate_message_will_be_marked_warning">El mensaje será marcado como moderado para todos los miembros.</string>
<string name="next_generation_of_private_messaging">La próxima generación de mensajería privada</string>
<string name="next_generation_of_private_messaging">La nueva generación de mensajería privada</string>
<string name="delete_files_and_media_desc">Esta acción no se puede deshacer. Se eliminarán todos los archivos y multimedia recibidos y enviados. Las imágenes de baja resolución permanecerán.</string>
<string name="enable_automatic_deletion_message">Esta acción no se puede deshacer. Se eliminarán los mensajes enviados y recibidos anteriores a la selección. Puede tardar varios minutos.</string>
<string name="messages_section_description">Esta configuración se aplica a los mensajes del perfil actual</string>
@@ -914,7 +914,7 @@
<string name="your_settings">Configuración</string>
<string name="your_SMP_servers">Servidores SMP</string>
<string name="you_control_your_chat">¡Tú controlas tu chat!</string>
<string name="your_profile_is_stored_on_your_device">Tu perfil, contactos y mensajes entregados se almacenan en tu dispositivo.</string>
<string name="your_profile_is_stored_on_your_device">Tu perfil, contactos y mensajes se almacenan en tu dispositivo.</string>
<string name="callstate_waiting_for_answer">esperando respuesta…</string>
<string name="callstate_waiting_for_confirmation">esperando confirmación…</string>
<string name="onboarding_notifications_mode_off">Cuando la aplicación se está ejecutando</string>
@@ -923,7 +923,7 @@
<string name="your_ice_servers">Servidores ICE</string>
<string name="your_privacy">Privacidad</string>
<string name="settings_section_title_you">MIS DATOS</string>
<string name="your_chat_database">Base de datos Chat</string>
<string name="your_chat_database">Base de datos</string>
<string name="you_can_start_chat_via_setting_or_by_restarting_the_app">Puedes iniciar el chat en Configuración / Base de datos o reiniciando la aplicación.</string>
<string name="you_sent_group_invitation">Has enviado una invitación de grupo</string>
<string name="num_contacts_selected">%d contacto(s) seleccionado(s)</string>
@@ -1126,7 +1126,7 @@
<string name="learn_more">Más información</string>
<string name="if_you_cant_meet_in_person">Si no puedes reunirte en persona, **muestra el código QR por videollamada**, o comparte el enlace.</string>
<string name="scan_qr_to_connect_to_contact">Para conectarse, tu contacto puede escanear el código QR o usar el enlace en la aplicación.</string>
<string name="create_simplex_address">Crear dirección SimpleX</string>
<string name="create_simplex_address">Crear tu dirección SimpleX</string>
<string name="auto_accept_contact">Auto aceptar</string>
<string name="group_welcome_preview">Vista previa</string>
<string name="opening_database">Abriendo base de datos…</string>
@@ -1144,15 +1144,15 @@
<string name="export_theme">Exportar tema</string>
<string name="color_surface">Menús y alertas</string>
<string name="add_address_to_your_profile">Añade la dirección a tu perfil para que tus contactos puedan compartirla con otros. La actualización del perfil se enviará a tus contactos.</string>
<string name="learn_more_about_address">Acerca de dirección SimpleX</string>
<string name="learn_more_about_address">Acerca de la dirección SimpleX</string>
<string name="address_section_title">Dirección</string>
<string name="all_your_contacts_will_remain_connected_update_sent">Todos tus contactos permanecerán conectados. La actualización del perfil se enviará a tus contactos.</string>
<string name="continue_to_next_step">Continuar</string>
<string name="dark_theme">Tema oscuro</string>
<string name="customize_theme_title">Personalizar tema</string>
<string name="enter_welcome_message_optional">Introduce mensaje de bienvenida… (opcional)</string>
<string name="create_address_and_let_people_connect">Crear una dirección para que otras personas se puedan conectar contigo.</string>
<string name="dont_create_address">No crear dirección</string>
<string name="create_address_and_let_people_connect">Crea una dirección para que otras personas puedan conectar contigo.</string>
<string name="dont_create_address">No crear dirección SimpleX</string>
<string name="email_invite_body">¡Hola!
\nConecta conmigo a través de SimpleX Chat: %s</string>
<string name="import_theme">Importar tema</string>
@@ -1170,7 +1170,7 @@
<string name="stop_sharing">Dejar de compartir</string>
<string name="stop_sharing_address">¿Dejar de compartir la dirección\?</string>
<string name="theme_colors_section_title">COLORES DEL TEMA</string>
<string name="you_can_create_it_later">Puedes crearlo más tarde</string>
<string name="you_can_create_it_later">Puedes crearla más tarde</string>
<string name="share_address_with_contacts_question">¿Compartir la dirección con los contactos\?</string>
<string name="share_with_contacts">Compartir con contactos</string>
<string name="color_title">Título</string>
@@ -82,7 +82,7 @@
<string name="info_row_connection">Yhteys</string>
<string name="ttl_d">%dd</string>
<string name="ttl_days">%d päivää</string>
<string name="disappearing_prohibited_in_this_chat">Tuhoutuvat viestit ovat kiellettyjä tässä keskustelussa.</string>
<string name="disappearing_prohibited_in_this_chat">Katoavat viestit ovat kiellettyjä tässä keskustelussa.</string>
<string name="network_session_mode_user_description"><![CDATA[Erillistä TCP-yhteyttä (ja SOCKS-tunnistetietoja) käytetään <b>jokaisessa käyttämässäsi sovelluksen chat-profiilissa</b>.]]></string>
<string name="app_version_code">Sovellusversio: %s</string>
<string name="delete_address">Poista osoite</string>
@@ -140,7 +140,7 @@
<string name="delete_verb">Poista</string>
<string name="delete_message__question">Poista viesti\?</string>
<string name="group_connection_pending">yhdistää…</string>
<string name="disappearing_message">Tuhoutuva viesti</string>
<string name="disappearing_message">Katoava viesti</string>
<string name="send_disappearing_message_custom_time">Mukautettu aika</string>
<string name="delete_contact_menu_action">Poista</string>
<string name="delete_group_menu_action">Poista</string>
@@ -158,7 +158,7 @@
<string name="settings_developer_tools">Kehittäjän työkalut</string>
<string name="cannot_access_keychain">Ei pääsyä Keystoreen tietokannan salasanan tallentamiseksi</string>
<string name="share_text_database_id">Tietokannan tunnus: %d</string>
<string name="info_row_disappears_at">Tuhoutuu klo</string>
<string name="info_row_disappears_at">Katoaa klo</string>
<string name="chat_database_deleted">Keskustelujen tietokanta poistettu</string>
<string name="delete_chat_profile_question">Poista keskusteluprofiili\?</string>
<string name="delete_messages_after">Poista viestit tämän jälkeen</string>
@@ -187,7 +187,7 @@
<string name="audio_call_no_encryption">äänipuhelu (ei e2e-salattu)</string>
<string name="always_use_relay">Käytä aina relettä</string>
<string name="allow_your_contacts_to_send_disappearing_messages">Salli kontaktiesi lähettää katoavia viestejä.</string>
<string name="timed_messages">Tuhoutuvat viestit</string>
<string name="timed_messages">Katoavat viestit</string>
<string name="icon_descr_context">Kontekstikuvake</string>
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><![CDATA[<b>Skannaa QR-koodi</b>: muodostaaksesi yhteyden kontaktiisi, joka näyttää QR-koodin sinulle.]]></string>
<string name="icon_descr_cancel_live_message">Peruuta live-viesti</string>
@@ -199,7 +199,7 @@
<string name="settings_audio_video_calls">Ääni- ja videopuhelut</string>
<string name="call_on_lock_screen">Puhelut lukitusnäytöllä:</string>
<string name="conn_level_desc_direct">suora</string>
<string name="disappearing_messages_are_prohibited">Tuhoutuvat viestit ovat kiellettyjä tässä ryhmässä.</string>
<string name="disappearing_messages_are_prohibited">Katoavat viestit ovat kiellettyjä tässä ryhmässä.</string>
<string name="server_connected">yhdistetty</string>
<string name="display_name_connecting">yhdistää…</string>
<string name="display_name_connection_established">yhteys luotu</string>
@@ -240,7 +240,7 @@
<string name="both_you_and_your_contact_can_send_disappearing">Sekä sinä että kontaktisi voitte lähettää katoavia viestejä.</string>
<string name="chat_preferences_contact_allows">Kontakti sallii</string>
<string name="chat_preferences_default">oletus (%s)</string>
<string name="v4_4_disappearing_messages">Tuhoutuvat viestit</string>
<string name="v4_4_disappearing_messages">Katoavat viestit</string>
<string name="copied">Kopioitu leikepöydälle</string>
<string name="share_one_time_link">Luo kertaluonteinen kutsulinkki</string>
<string name="mtr_error_no_down_migration">tietokantaversio on uudempi kuin sovellus, mutta ei alaspäin siirtymistä: %s</string>
@@ -336,7 +336,7 @@
<string name="change_verb">Muuta</string>
<string name="item_info_current">(nykyinen)</string>
<string name="share_text_deleted_at">Poistettu: %s</string>
<string name="share_text_disappears_at">Tuhoutuu klo: %s</string>
<string name="share_text_disappears_at">Katoaa klo: %s</string>
<string name="create_secret_group_title">Luo salainen ryhmä</string>
<string name="chat_preferences_always">aina</string>
<string name="cant_delete_user_profile">Käyttäjäprofiilia ei voi poistaa!</string>
@@ -406,7 +406,7 @@
<string name="join_group_question">Liity ryhmään\?</string>
<string name="network_option_enable_tcp_keep_alive">Ota TCP-säilytys käyttöön</string>
<string name="incognito">Incognito</string>
<string name="incognito_info_protects">Incognito-tila suojaa pääprofiilisi nimen ja kuvan yksityisyyttä – jokaiselle uudelle yhteyshenkilölle luodaan uusi satunnainen profiili.</string>
<string name="incognito_info_protects">Incognito-tila suojaa yksityisyyttäsi käyttämällä uutta satunnaista profiilia jokaiselle kontaktille.</string>
<string name="ttl_mth">%dmth</string>
<string name="ttl_m">%dm</string>
<string name="v4_6_group_welcome_message">Ryhmän tervetuloviesti</string>
@@ -451,7 +451,7 @@
<string name="import_database_question">Tuo keskustelujen-tietokanta\?</string>
<string name="file_with_path">Tiedosto: %s</string>
<string name="error_removing_member">Virhe poistettaessa jäsentä</string>
<string name="message_deletion_prohibited">Viestien peruuttamaton poistaminen on kielletty tässä keskustelussa.</string>
<string name="message_deletion_prohibited">Viestien peruuttamaton poisto on kielletty tässä keskustelussa.</string>
<string name="join_group_button">Liity</string>
<string name="join_group_incognito_button">Liity incognito-tilassa</string>
<string name="joining_group">Liittyy ryhmään</string>
@@ -474,10 +474,10 @@
<string name="image_will_be_received_when_contact_is_online">Kuva vastaanotetaan, kun kontaktisi on verkossa, odota tai tarkista myöhemmin!</string>
<string name="if_you_cant_meet_in_person">Jos et voi tavata henkilökohtaisesti, näytä QR-koodi videopuhelussa tai jaa linkki.</string>
<string name="onboarding_notifications_mode_subtitle">Voit muuttaa sitä myöhemmin asetuksista.</string>
<string name="encrypt_database_question">Salataanko tietokanta\?</string>
<string name="encrypt_database_question">Salaa tietokanta\?</string>
<string name="button_edit_group_profile">Muokkaa ryhmäprofiilia</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Ryhmä poistetaan kaikilta jäseniltä - tätä ei voi kumota!</string>
<string name="delete_group_for_self_cannot_undo_warning">Ryhmä poistetaan sinulta - tätä ei voi peruuttaa!</string>
<string name="delete_group_for_self_cannot_undo_warning">Ryhmä poistetaan sinulta - tätä ei voi perua!</string>
<string name="error_creating_link_for_group">Virhe ryhmälinkin luomisessa</string>
<string name="info_row_group">Ryhmä</string>
<string name="incognito_info_allows">Se mahdollistaa useiden nimettömien yhteyksien muodostamisen yhdessä keskusteluprofiilissa ilman, että niiden välillä on jaettuja tietoja.</string>
@@ -514,7 +514,7 @@
<string name="onboarding_notifications_mode_service">Välitön</string>
<string name="encrypted_audio_call">e2e-salattu äänipuhelu</string>
<string name="allow_accepting_calls_from_lock_screen">Ota puhelut käyttöön lukitusnäytöltä asetuksista.</string>
<string name="status_e2e_encrypted">e2e salattu</string>
<string name="status_e2e_encrypted">e2e-salattu</string>
<string name="settings_section_title_incognito">Incognito-tila</string>
<string name="error_with_info">Virhe: %s</string>
<string name="alert_message_group_invitation_expired">Ryhmäkutsu ei ole enää voimassa, lähettäjä poisti sen.</string>
@@ -543,15 +543,15 @@
<string name="downgrade_and_open_chat">Alenna ja avaa chat</string>
<string name="icon_descr_group_inactive">Ei-aktiivinen ryhmä</string>
<string name="v4_3_improved_privacy_and_security_desc">Piilota sovellusnäyttö viimeisimmissä sovelluksissa.</string>
<string name="v4_5_italian_interface">Italian käyttöliittymä</string>
<string name="v5_0_large_files_support_descr">Nopea ja ei odota, kunnes lähettäjä on online-tilassa!</string>
<string name="v4_5_italian_interface">Italialainen käyttöliittymä</string>
<string name="v5_0_large_files_support_descr">Nopea ja ei odotusta, kunnes lähettäjä on online-tilassa!</string>
<string name="v4_6_reduced_battery_usage">Entisestä vähentynyt akun käyttö</string>
<string name="v5_1_japanese_portuguese_interface">Japanin ja portugalin käyttöliittymä</string>
<string name="error_saving_file">Virhe tiedoston tallentamisessa</string>
<string name="icon_descr_help">apua</string>
<string name="incorrect_code">Väärä turvakoodi!</string>
<string name="error_sending_message">Virhe viestin lähettämisessä</string>
<string name="turn_off_battery_optimization"><![CDATA[Jotta voit käyttää sitä, <b>poista akun optimointi käytöstä</b> kohteelle SimpleX seuraavassa valintaikkunassa. Muussa tapauksessa ilmoitukset poistetaan käytöstä.]]></string>
<string name="turn_off_battery_optimization"><![CDATA[Käyttääksesi sitä, <b> salli SimpleX:n toimia taustalla </b> seuraavassa ikkunassa. Muutoin ilmoitukset poistetaan käytöstä.]]></string>
<string name="notification_preview_mode_hidden">Piilotettu</string>
<string name="la_immediately">Heti</string>
<string name="la_enter_app_passcode">Syötä pääsykoodi</string>
@@ -570,7 +570,7 @@
<string name="enable_lock">Ota lukitus käyttöön</string>
<string name="group_invitation_item_description">kutsu ryhmään %1$s</string>
<string name="icon_descr_add_members">Kutsu jäseniä</string>
<string name="group_invitation_expired">Ryhmäkutsu on vanhentunut</string>
<string name="group_invitation_expired">Vanhentunut ryhmäkutsu</string>
<string name="rcv_group_event_member_added">kutsuttu %1$s</string>
<string name="group_full_name_field">Ryhmän koko nimi:</string>
<string name="full_name_optional__prompt">Koko nimi (valinnainen)</string>
@@ -592,7 +592,7 @@
<string name="section_title_for_console">KONSOLIIN</string>
<string name="group_member_status_group_deleted">poistettu ryhmä</string>
<string name="snd_group_event_group_profile_updated">ryhmäprofiili päivitetty</string>
<string name="alert_title_group_invitation_expired">Kutsu on vanhentunut!</string>
<string name="alert_title_group_invitation_expired">Vanhentunut kutsu!</string>
<string name="group_member_status_invited">kutsuttu</string>
<string name="conn_level_desc_indirect">epäsuora (%1$s)</string>
<string name="group_display_name_field">Ryhmän näyttönimi:</string>
@@ -602,10 +602,10 @@
<string name="group_members_can_delete">Ryhmän jäsenet voivat poistaa lähetetyt viestit peruuttamattomasti.</string>
<string name="group_members_can_send_dms">Ryhmän jäsenet voivat lähettää suoraviestejä.</string>
<string name="group_members_can_send_voice">Ryhmän jäsenet voivat lähettää ääniviestejä.</string>
<string name="message_deletion_prohibited_in_chat">Viestien peruuttamaton poistaminen on kielletty tässä ryhmässä.</string>
<string name="message_deletion_prohibited_in_chat">Viestien peruuttamaton poisto on kielletty tässä ryhmässä.</string>
<string name="ttl_months">%d kuukautta</string>
<string name="ttl_sec">%d sek</string>
<string name="v5_1_message_reactions_descr">Vihdoinkin meillä on ne! 🚀</string>
<string name="v5_1_message_reactions_descr">Vihdoinkin meillä! 🚀</string>
<string name="custom_time_unit_hours">tuntia</string>
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Tarkista, että käytit oikeaa linkkiä tai pyydä kontaktiasi lähettämään sinulle uusi linkki.</string>
<string name="sender_may_have_deleted_the_connection_request">Lähettäjä on saattanut poistaa yhteyspyynnön.</string>
@@ -619,7 +619,7 @@
<string name="notification_preview_mode_message">Viestin teksti</string>
<string name="notification_preview_mode_contact_desc">Näytä vain kontakti</string>
<string name="notification_preview_new_message">uusi viesti</string>
<string name="la_notice_title_simplex_lock">Simplex Lock</string>
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>
<string name="auth_simplex_lock_turned_on">SimpleX Lock päällä</string>
<string name="auth_open_chat_console">Avaa keskustelukonsoli</string>
<string name="message_delivery_error_title">Viestin toimitusvirhe</string>
@@ -848,7 +848,7 @@
<string name="read_more_in_github_with_link"><![CDATA[Lue lisää <font color="#0088ff">GitHub-arkistostamme</font>.]]></string>
<string name="onboarding_notifications_mode_periodic">Säännölliset</string>
<string name="only_client_devices_store_contacts_groups_e2e_encrypted_messages"><![CDATA[Vain asiakaslaitteet tallentavat käyttäjäprofiileja, yhteystietoja, ryhmiä ja viestejä, jotka on lähetetty <b>2-kerroksisella päästä päähän -salauksella</b>.]]></string>
<string name="read_more_in_github">Lue lisää GitHub-arkistostamme.</string>
<string name="read_more_in_github">Lue lisää GitHub-tietovarastostamme.</string>
<string name="paste_the_link_you_received">Liitä vastaanotettu linkki</string>
<string name="relay_server_protects_ip">Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa.</string>
<string name="open_simplex_chat_to_accept_call">Avaa SimpleX Chat hyväksyäksesi puhelun</string>
@@ -1007,7 +1007,7 @@
<string name="prohibit_message_reactions">Estä viestireaktiot.</string>
<string name="prohibit_sending_disappearing_messages">Estä katoavien viestien lähettäminen.</string>
<string name="accept_feature_set_1_day">Aseta 1 päivä</string>
<string name="only_your_contact_can_make_calls">Vain yhteyshenkilösi voi soittaa puheluita.</string>
<string name="only_your_contact_can_make_calls">Vain kontaktisi voi soittaa puheluita.</string>
<string name="prohibit_calls">Estä ääni- ja videopuhelut.</string>
<string name="prohibit_direct_messages">Estä suorien viestien lähettäminen jäsenille.</string>
<string name="message_reactions_prohibited_in_this_chat">Viestireaktiot ovat kiellettyjä tässä keskustelussa.</string>
@@ -1043,7 +1043,7 @@
<string name="xftp_servers">XFTP-palvelimet</string>
<string name="smp_servers_your_server">Palvelimesi</string>
<string name="smp_servers_your_server_address">Palvelimesi osoite</string>
<string name="enable_automatic_deletion_message">Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Se voi kestää useita minuutteja.</string>
<string name="enable_automatic_deletion_message">Tätä toimintoa ei voi kumota - valittua aikaisemmin lähetetyt ja vastaanotetut viestit poistetaan. Tämä voi kestää useita minuutteja.</string>
<string name="whats_new">Uusimmat</string>
<string name="you_will_still_receive_calls_and_ntfs">Saat edelleen puheluita ja ilmoituksia mykistetyiltä profiileilta, kun ne ovat aktiivisia.</string>
<string name="network_disable_socks">Käytä suoraa Internet-yhteyttä\?</string>
@@ -1199,7 +1199,7 @@
<string name="videos_limit_title">Liikaa videoita!</string>
<string name="voice_message">Ääniviesti</string>
<string name="waiting_for_video">Odottaa videota</string>
<string name="switch_receiving_address_desc">Tämä ominaisuus on kokeellinen! Se toimii vain, jos toisella on asennettuna versio 4.2. Sinun pitäisi nähdä viesti keskustelussa, kun osoitteenmuutos on valmis - tarkista, että voit edelleen vastaanottaa viestejä kyseiseltä kontaktilta (tai ryhmän jäseneltä).</string>
<string name="switch_receiving_address_desc">Vastaanotto-osoite vaihdetaan toiseen palvelimeen. Osoitteenmuutos tehdään sen jälkeen, kun lähettäjä tulee verkkoon.</string>
<string name="you_need_to_allow_to_send_voice">Sinun on sallittava kontaktiesi lähettää ääniviestejä, jotta voit lähettää niitä.</string>
<string name="you_can_connect_to_simplex_chat_founder"><![CDATA[Voit <font color="#0088ff">olla yhteydessä SimpleX Chatin -kehittäjiin kysyäksesi kysymyksiä ja saadaksesi päivityksiä</font>.]]></string>
<string name="this_QR_code_is_not_a_link">Tämä QR-koodi ei ole linkki!</string>
@@ -1260,4 +1260,118 @@
<string name="settings_restart_app">Käynnistä uudelleen</string>
<string name="shutdown_alert_question">Sulje\?</string>
<string name="la_mode_off">Pois</string>
<string name="v5_2_message_delivery_receipts">Viestien toimituskuittaukset!</string>
<string name="v5_2_more_things_descr">- vakaampi viestien toimitus.
\n- hieman paremmat ryhmät.
\n- ja paljon muuta!</string>
<string name="delivery_receipts_are_disabled">Toimituskuittaukset poissa käytöstä!</string>
<string name="you_can_enable_delivery_receipts_later">Voit ottaa käyttöön myöhemmin asetusten kautta</string>
<string name="you_can_enable_delivery_receipts_later_alert">Voit ottaa ne käyttöön myöhemmin sovelluksen Yksityisyys &amp; Turvallisuus -asetuksista.</string>
<string name="error_aborting_address_change">Virhe osoitteenmuutoksen keskeytyksessä</string>
<string name="abort_switch_receiving_address">Keskeytä osoitteenvaihto</string>
<string name="network_option_protocol_timeout_per_kb">Protokollan aikakatkaisu per KB</string>
<string name="files_are_prohibited_in_group">Tiedostot ja media ovat tässä ryhmässä kiellettyjä.</string>
<string name="group_members_can_send_files">Ryhmän jäsenet voivat lähettää tiedostoja ja mediaa.</string>
<string name="connect_via_link_incognito">Yhdistä Incognito</string>
<string name="connect_use_current_profile">Käytä nykyistä profiilia</string>
<string name="connect_use_new_incognito_profile">Käytä uutta incognito-profiilia</string>
<string name="turn_off_battery_optimization_button">Salli</string>
<string name="disable_notifications_button">Poista ilmoitukset käytöstä</string>
<string name="turn_off_system_restriction_button">Avaa asetukset</string>
<string name="system_restricted_background_desc">SimpleX ei toimi tausta-ajossa. Saat ilmoitukset ainostaan, kun sovellus on käynnissä.</string>
<string name="system_restricted_background_warn"><![CDATA[Ilmoitusten sallimiseksi valitse <b> Sovelluksen akun käyttö </b> / <b> rajoittamaton </b> sovellusasetuksista.]]></string>
<string name="system_restricted_background_in_call_title">Ei taustapuheluita</string>
<string name="system_restricted_background_in_call_desc">Sovellus voi sulkeutua 1 minuutin jälkeen tausta-ajossa.</string>
<string name="system_restricted_background_in_call_warn"><![CDATA[Puheluiden soittamiseksi taustalla, valitse <b>Sovelluksen akun käyttö </b> / <b> rajoittamaton </b> sovellusasetuksista.]]></string>
<string name="in_reply_to">Vastauksena</string>
<string name="abort_switch_receiving_address_question">Keskeytä osoitteenvaihto\?</string>
<string name="favorite_chat">Suosikki</string>
<string name="unfavorite_chat">Epäsuosikki</string>
<string name="connect__a_new_random_profile_will_be_shared">Uusi satunnainen profiili jaetaan.</string>
<string name="paste_the_link_you_received_to_connect_with_your_contact">Liitä linkki, jonka sait yhteydenottoon kontaktisi kanssa…</string>
<string name="connect__your_profile_will_be_shared">Profiilisi %1$s jaetaan.</string>
<string name="receipts_groups_title_disable">Kuittaukset pois käytöstä ryhmiltä\?</string>
<string name="in_developing_title">Tulossa pian!</string>
<string name="receipts_groups_disable_for_all">Poista käytöstä kaikilta ryhmiltä</string>
<string name="files_and_media">Tiedostot ja media</string>
<string name="receipts_groups_enable_keep_overrides">Salli (pidä ryhmäohitukset)</string>
<string name="sending_delivery_receipts_will_be_enabled_all_profiles">"Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille näkyvissä keskusteluprofiileissa."</string>
<string name="receipts_groups_override_enabled">Kuittauksien lähettäminen on käytössä %d ryhmille</string>
<string name="abort_switch_receiving_address_confirm">Keskeytä</string>
<string name="sync_connection_force_confirm">Uudelleenneuvottele</string>
<string name="sync_connection_force_question">Uudelleenneuvottele salaus\?</string>
<string name="sync_connection_force_desc">Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin!</string>
<string name="receipts_section_description">Nämä asetukset koskevat nykyistä profiiliasi</string>
<string name="receipts_section_description_1">Ne voidaan ohittaa kontakti- ja ryhmäasetuksissa.</string>
<string name="conn_event_ratchet_sync_ok">salaus ok</string>
<string name="conn_event_ratchet_sync_allowed">salauksen uudelleenneuvottelu sallittu</string>
<string name="fix_connection_confirm">Korjaa</string>
<string name="fix_connection_not_supported_by_contact">Kontakti ei tue korjausta</string>
<string name="fix_connection_not_supported_by_group_member">Ryhmän jäsen ei tue korjausta</string>
<string name="renegotiate_encryption">Uudelleenneuvottele salaus</string>
<string name="in_developing_desc">Tätä ominaisuutta ei vielä tueta. Kokeile seuraavaa versiota.</string>
<string name="delivery">Toimitus</string>
<string name="no_info_on_delivery">Ei toimitustietoja</string>
<string name="no_filtered_chats">Ei suodatettuja keskusteluja</string>
<string name="no_selected_chat">Ei valittua keskustelua</string>
<string name="only_owners_can_enable_files_and_media">Vain ryhmän omistajat voivat sallia tiedostoja ja mediaa.</string>
<string name="files_and_media_prohibited">Tiedostot ja media kielletty!</string>
<string name="abort_switch_receiving_address_desc">Osoitteenmuutos keskeytetään. Käytetään vanhaa vastaanotto-osoitetta.</string>
<string name="choose_file_title">Valitse tiedosto</string>
<string name="receipts_section_groups">Pienryhmät (max 20)</string>
<string name="send_receipts_disabled_alert_title">Kuittaukset pois käytöstä</string>
<string name="send_receipts_disabled_alert_msg">Ryhmällä on yli %1$d jäsentä, toimituskuittauksia ei lähetetä.</string>
<string name="recipient_colon_delivery_status">%s: %s</string>
<string name="v5_2_more_things">Muutama asia lisää</string>
<string name="v5_2_fix_encryption">Pidä kontaktisi</string>
<string name="receipts_section_contacts">Kontaktit</string>
<string name="receipts_contacts_disable_for_all">Poista käytöstä kaikilta</string>
<string name="receipts_contacts_enable_for_all">Salli kaikille</string>
<string name="receipts_contacts_enable_keep_overrides">Salli (pidä ohitukset)</string>
<string name="receipts_contacts_disable_keep_overrides">Poista käytöstä (pidä ohitukset)</string>
<string name="receipts_contacts_title_disable">Kuittaukset pois käytöstä\?</string>
<string name="receipts_contacts_title_enable">Salli kuittaukset\?</string>
<string name="receipts_contacts_override_disabled">Kuittauksien lähettäminen on pois käytöstä %d kontakteilta</string>
<string name="receipts_contacts_override_enabled">Kuittauksien lähettäminen on käytössä %d kontakteille</string>
<string name="settings_section_title_delivery_receipts">LÄHETÄ TOIMITUSKUITTAUKSET VASTAANOTTAJALLE</string>
<string name="rcv_conn_event_verification_code_reset">turvakoodi on muuttunut</string>
<string name="conn_event_ratchet_sync_started">hyväksyy salausta…</string>
<string name="snd_conn_event_ratchet_sync_allowed">salauksen uudelleenneuvottelu sallittu %s:lle</string>
<string name="conn_event_ratchet_sync_required">tarvitaan salauksen uudelleenneuvottelua</string>
<string name="snd_conn_event_ratchet_sync_started">hyväksyy salausta %s:lle…</string>
<string name="conn_event_ratchet_sync_agreed">salaus sovittu</string>
<string name="snd_conn_event_ratchet_sync_agreed">salaus sovittu %s:lle</string>
<string name="snd_conn_event_ratchet_sync_ok">salaus ok %s:lle</string>
<string name="snd_conn_event_ratchet_sync_required">tarvitaan salauksen uudelleenneuvottelua %s:lle</string>
<string name="sender_at_ts">"%s klo %s"</string>
<string name="send_receipts">Lähetä kuittaukset</string>
<string name="fix_connection">Korjaa yhteys</string>
<string name="fix_connection_question">Korjaa yhteys\?</string>
<string name="allow_to_send_files">Salli tiedostojen ja median lähettäminen.</string>
<string name="prohibit_sending_files">Estä tiedostojen ja median lähettäminen.</string>
<string name="v5_2_disappear_one_message">Hävitä yksi viesti</string>
<string name="v5_2_fix_encryption_descr">Korjaa salaus varmuuskopioiden palauttamisen jälkeen.</string>
<string name="v5_2_disappear_one_message_descr">Jopa kun ei käytössä keskustelussa.</string>
<string name="receipts_groups_disable_keep_overrides">Poista käytöstä (pidä ryhmäohitukset)</string>
<string name="receipts_groups_enable_for_all">Salli kaikille ryhmille</string>
<string name="receipts_groups_title_enable">Salli kuittaukset ryhmille\?</string>
<string name="privacy_message_draft">Viestiluonnos</string>
<string name="receipts_groups_override_disabled">Kuittauksien lähettäminen on pois käytöstä %d ryhmiltä</string>
<string name="privacy_show_last_messages">Näytä viimeiset viestit</string>
<string name="send_receipts_disabled">ei käytössä</string>
<string name="rcv_group_event_2_members_connected">%s ja %s yhdistetty</string>
<string name="rcv_group_event_n_members_connected">%s, %s ja %d muut jäsenet yhdistetty</string>
<string name="rcv_group_event_3_members_connected">%s, %s ja %s yhdistetty</string>
<string name="dont_enable_receipts">Älä salli</string>
<string name="error_enabling_delivery_receipts">Virhe toimituskuittauksien sallimisessa!</string>
<string name="connect_via_member_address_alert_title">Yhdistä suoraan\?</string>
<string name="connect_via_member_address_alert_desc">Yhteyspyyntö lähetetään tälle ryhmän jäsenelle.</string>
<string name="delivery_receipts_title">Toimituskuittaukset!</string>
<string name="enable_receipts_all">Salli</string>
<string name="v5_2_favourites_filter_descr">Suodata lukemattomia- ja suosikkikeskusteluja.</string>
<string name="v5_2_favourites_filter">Löydä keskustelut nopeammin</string>
<string name="sending_delivery_receipts_will_be_enabled">Toimituskuittauksien lähettäminen otetaan käyttöön kaikille kontakteille.</string>
<string name="v5_2_message_delivery_receipts_descr">Toinen kuittaus, joka uupui! ✅</string>
<string name="error_synchronizing_connection">Virhe yhteyden synkronoinnissa</string>
<string name="no_history">Ei historiaa</string>
</resources>
@@ -1316,7 +1316,7 @@
<string name="receipts_contacts_override_enabled">L\'envoi d\'accusés de réception est activé pour les contacts de %d</string>
<string name="conn_event_ratchet_sync_started">accord sur le chiffrement…</string>
<string name="conn_event_ratchet_sync_agreed">chiffrement accepté</string>
<string name="conn_event_ratchet_sync_ok">chiffrement ok</string>
<string name="conn_event_ratchet_sync_ok">chiffrement OK</string>
<string name="conn_event_ratchet_sync_allowed">renégociation de chiffrement autorisée</string>
<string name="conn_event_ratchet_sync_required">renégociation de chiffrement requise</string>
<string name="snd_conn_event_ratchet_sync_ok">chiffrement ok pour %s</string>
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 96 960 960" width="24"><path d="M222 971q-23.719 0-40.609-16.891Q164.5 937.219 164.5 913.5v-431q0-23.719 16.891-40.609Q198.281 425 222 425h387v-95.385q0-53.782-37.373-91.198Q534.254 201 479.863 201q-46.363 0-81.363 28T354 300.5q-3 13-11.75 21.25T321.983 330q-12.311 0-20.397-8.5-8.086-8.5-6.086-20 10-68 61.902-113t122.629-45q77.383 0 131.926 54.551Q666.5 252.603 666.5 330v95H738q23.719 0 40.609 16.891Q795.5 458.781 795.5 482.5v431q0 23.719-16.891 40.609Q761.719 971 738 971H222Zm0-57.5h516v-431H222v431Zm258.084-140q31.179 0 53.297-21.566 22.119-21.566 22.119-51.85 0-29.347-22.203-53.465-22.203-24.119-53.381-24.119-31.179 0-53.297 24.035-22.119 24.034-22.119 53.881t22.203 51.465q22.203 21.619 53.381 21.619ZM222 482.5v431-431Z"/></svg>

Before

Width:  |  Height:  |  Size: 804 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M222-142.5h516v-431H222v431Zm258.084-140q31.179 0 53.297-21.566 22.119-21.566 22.119-51.85 0-29.347-22.203-53.465-22.203-24.119-53.381-24.119-31.179 0-53.297 24.035-22.119 24.034-22.119 53.881t22.203 51.465q22.203 21.619 53.381 21.619ZM222-142.5v-431 431Zm0 57.5q-23.719 0-40.609-16.891Q164.5-118.781 164.5-142.5v-431q0-23.719 16.891-40.609Q198.281-631 222-631h329.5v-95.018q0-77.832 54.349-132.157Q660.198-912.5 738-912.5q70 0 121.25 44T922-759q2 11.5-6.638 22.25T895.75-726q-12.66 0-20.705-6-8.045-6-9.545-18.5-9-44.5-44.55-74.5T738-855q-54.333 0-91.667 37.333Q609-780.333 609-726.231V-631h129q23.719 0 40.609 16.891Q795.5-597.219 795.5-573.5v431q0 23.719-16.891 40.609Q761.719-85 738-85H222Z"/></svg>

After

Width:  |  Height:  |  Size: 800 B

@@ -689,7 +689,7 @@
<string name="old_database_archive">ארכיון מסד נתונים ישן</string>
<string name="chat_item_ttl_none">לעולם לא</string>
<string name="no_received_app_files">לא התקבלו או נשלחו קבצים</string>
<string name="new_member_role">תפקיד חבר קבוצה</string>
<string name="new_member_role">תפקיד חבר קבוצה חדש</string>
<string name="no_contacts_selected">לא נבחרו אנשי קשר</string>
<string name="member_info_section_title_member">חבר קבוצה</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">חבר הקבוצה יוסר מהקבוצה – לא ניתן לבטל זאת!</string>
@@ -1257,7 +1257,7 @@
<string name="your_preferences">ההעדפות שלך</string>
<string name="you_will_still_receive_calls_and_ntfs">עדיין תקבלו שיחות והתראות מפרופילים מושתקים כאשר הם פעילים.</string>
<string name="abort_switch_receiving_address_confirm">בטל</string>
<string name="abort_switch_receiving_address_question">בטל שינוי כתובת\?</string>
<string name="abort_switch_receiving_address_question">האם לבטל שינוי כתובת\?</string>
<string name="abort_switch_receiving_address_desc">שינוי הכתובת יבוטל. ייעשה שימוש בכתובת הקבלה הישנה.</string>
<string name="shutdown_alert_desc">ההתראות יפסיקו לפעול עד שתפעיל את האפליקציה מחדש</string>
<string name="abort_switch_receiving_address">בטל שינוי כתובת</string>
@@ -167,7 +167,6 @@
<string name="icon_descr_call_ended">通話が終了しました。</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link"><![CDATA[直接会えない時は、 <b>ビデオ通話中にQRコードを見せてもらうか</b>、招待リンクを送ってもらえば相手に繋がります。]]></string>
<string name="member_will_be_removed_from_group_cannot_be_undone">メンバーをグループから除名する (※元に戻せません※)!</string>
<string name="message_delivery_error_title">メッセージ送信エラー</string>
<string name="call_on_lock_screen">通話をロック画面に表示</string>
<string name="icon_descr_cancel_link_preview">リンクのプレビューを中止</string>
<string name="icon_descr_cancel_live_message">ライブメッセージを中止</string>
@@ -485,7 +484,7 @@
<string name="smp_server_test_create_queue">サーバの待ち行列を作成する</string>
<string name="smp_server_test_delete_queue">待ち行列を削除</string>
<string name="smp_server_test_disconnect">切断</string>
<string name="turn_off_battery_optimization"><![CDATA[利用するには次の画面にてSimpleXに対する <b>電気省電力の設定をオフ</b> for SimpleX してください。そうしないと通知が無効になります。]]></string>
<string name="turn_off_battery_optimization"><![CDATA[利用するには次の画面にてSimpleXに対する <b>SimpleX のバックグラウンドでの実行を許可</b> してください。そうしないと通知が無効になります。]]></string>
<string name="database_initialization_error_title">データベースを起動できません。</string>
<string name="settings_notification_preview_title">通知のプレビュー</string>
<string name="simplex_service_notification_text">メッセージ受信中…</string>
@@ -588,7 +587,7 @@
<string name="error_removing_member">メンバー除名にエラー発生</string>
<string name="conn_level_desc_indirect">関節 (%1$s)</string>
<string name="incognito">シークレットモード</string>
<string name="incognito_info_protects">シークレットモードとは、メインのプロフィールとプロフィール画像を守るために、新しい連絡先を追加する時に、その連絡先に対してランダムなプロフィールが作成されるという対策です。</string>
<string name="incognito_info_protects">シークレット モードでは、連絡先ごとに新しいランダムなプロファイルを使用してプライバシーを保護します。</string>
<string name="chat_preferences_no">いいえ</string>
<string name="chat_preferences_on">オン</string>
<string name="direct_messages">ダイレクトメッセージ</string>
@@ -904,7 +903,7 @@
<string name="simplex_link_mode">SimpleXリンク</string>
<string name="settings_section_title_support">SIMPLEX CHATを支援</string>
<string name="smp_servers_test_servers">テストサーバ</string>
<string name="switch_receiving_address_desc">開発中の機能です!相手のクライアントが4.2でなければ機能しません。アドレス変更が完了すると、会話にメッセージが出ます。連絡相手 (またはグループのメンバー) からメッセージを受信できないかをご確認ください。</string>
<string name="switch_receiving_address_desc">受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery"><![CDATA[あなたのプライバシーを守るために、このアプリはプッシュ通知の変わりに <b>SimpleX バックグラウンド・サービス</b> を使ってます。一日の電池使用量は約3%です。]]></string>
<string name="to_protect_privacy_simplex_has_ids_for_queues">あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。</string>
<string name="group_main_profile_sent">あなたのチャットプロフィールが他のグループメンバーに送られます。</string>
@@ -1258,9 +1257,9 @@
<string name="choose_file_title">ファイルを選択</string>
<string name="unfavorite_chat">お気に入りを解除</string>
<string name="favorite_chat">お気に入り</string>
<string name="receipts_contacts_override_enabled">連絡先 %d に対して既読の通知が有効です</string>
<string name="receipts_contacts_override_enabled">Sending receipts is enabled for %d contacts</string>
<string name="receipts_contacts_enable_for_all">すべて有効</string>
<string name="receipts_contacts_override_disabled">連絡先 %d に対して既読の通知が無効です</string>
<string name="receipts_contacts_override_disabled">Sending receipts is disabled for %d contacts</string>
<string name="receipts_contacts_disable_for_all">すべて無効</string>
<string name="settings_shutdown">終了</string>
<string name="conn_event_ratchet_sync_started">暗号化に同意しています…</string>
@@ -1289,7 +1288,7 @@
<string name="error_aborting_address_change">アドレス変更中止エラー</string>
<string name="no_filtered_chats">フィルタされたチャットはありません</string>
<string name="files_and_media_prohibited">ファイルとメディアは禁止されています!</string>
<string name="abort_switch_receiving_address_desc">住所変更は中止されます。古い受信アドレスが使用されます。</string>
<string name="abort_switch_receiving_address_desc">アドレス変更は中止されます。古い受信アドレスが使用されます。</string>
<string name="abort_switch_receiving_address_question">アドレス変更を中止しますか?</string>
<string name="settings_section_title_app">アプリ</string>
<string name="non_fatal_errors_occured_during_import">インポート中に致命的でないエラーが発生しました - 詳細はチャットコンソールを参照してください。</string>
@@ -1304,18 +1303,17 @@
<string name="v5_2_fix_encryption_descr">バックアップの復元後に暗号化を修正します。</string>
<string name="snd_conn_event_ratchet_sync_started">暗号化に同意しています: %s</string>
<string name="conn_event_ratchet_sync_agreed">暗号化に同意しました</string>
<string name="receipts_contacts_title_disable">既読通知を無効にしますか?</string>
<string name="receipts_contacts_title_disable">Disable receipts\?</string>
<string name="fix_connection_not_supported_by_contact">連絡先による修正はサポートされていません</string>
<string name="fix_connection_not_supported_by_group_member">グループメンバーによる修正はサポートされていません</string>
<string name="receipts_section_contacts">連絡先</string>
<string name="receipts_section_description_1">これらは連絡先の設定が優先します</string>
<string name="receipts_section_description_1">これらは連絡先とグループの設定が優先されます。</string>
<string name="receipts_section_description">これらの設定は現在のプロファイル用です</string>
<string name="receipts_contacts_title_enable">既読通知を有効にしますか?</string>
<string name="receipts_contacts_title_enable">Enable receipts\?</string>
<string name="sender_at_ts">%s : %s</string>
<string name="fix_connection">接続を修正</string>
<string name="files_and_media">ファイルとメディア</string>
<string name="you_can_enable_delivery_receipts_later_alert">あとでアプリのプライバシーとセキュリティの設定から有効にすることができます。</string>
<string name="error_enabling_delivery_receipts">既読通知の有効化でエラーが発生しました!</string>
<string name="item_info_no_text">テキストなし</string>
<string name="error_synchronizing_connection">接続の同期エラー</string>
<string name="no_history">履歴はありません</string>
@@ -1326,4 +1324,56 @@
<string name="only_owners_can_enable_files_and_media">ファイルやメディアを有効にできるのは、グループオーナーだけです。</string>
<string name="sync_connection_force_confirm">再ネゴシエート</string>
<string name="settings_restart_app">再起動</string>
<string name="connect_via_link_incognito">シークレットモードで接続</string>
<string name="turn_off_battery_optimization_button">許可</string>
<string name="connect_via_member_address_alert_title">直接接続しますか\?</string>
<string name="connect__a_new_random_profile_will_be_shared">新しいランダムなプロファイルが共有されます。</string>
<string name="delivery">送信</string>
<string name="in_developing_title">近日公開!</string>
<string name="v5_2_disappear_one_message">メッセージを1つ消す</string>
<string name="connect_use_current_profile">現在のプロファイルを使用する</string>
<string name="connect_use_new_incognito_profile">新しいシークレットプロファイルを使用する</string>
<string name="turn_off_system_restriction_button">アプリの設定を開く</string>
<string name="disable_notifications_button">通知を無効にする</string>
<string name="system_restricted_background_warn"><![CDATA[通知を有効にするには、アプリの設定で<b>アプリのバッテリー使用量</b> / <b>制限なし</b> を選択してください。]]></string>
<string name="system_restricted_background_in_call_desc">アプリはバックグラウンドで1分経過すると終了します。</string>
<string name="system_restricted_background_in_call_warn"><![CDATA[バックグラウンドで通話を行うには、アプリの設定で<b>アプリのバッテリー使用量</b> / <b>制限なし</b> を選択してください。]]></string>
<string name="connect__your_profile_will_be_shared">あなたのプロフィール %1$s が共有されます。</string>
<string name="receipts_groups_title_enable">Enable receipts for groups\?</string>
<string name="receipts_groups_title_disable">Disable receipts for groups\?</string>
<string name="receipts_groups_override_enabled">Sending receipts is enabled for %d groups</string>
<string name="receipts_groups_override_disabled">Sending receipts is disabled for %d groups</string>
<string name="send_receipts_disabled">無効</string>
<string name="system_restricted_background_in_call_title">バックグラウンド通話なし</string>
<string name="paste_the_link_you_received_to_connect_with_your_contact">受信したリンクを貼り付け、連絡先に接続する。</string>
<string name="rcv_group_event_2_members_connected">%s と %s は接続中</string>
<string name="system_restricted_background_desc">SimpleXはバックグラウンドでは動作できません。アプリが起動している時のみ通知を受け取ることができます。</string>
<string name="no_info_on_delivery">送信情報なし</string>
<string name="no_selected_chat">チャットが選択されていません</string>
<string name="receipts_section_groups">小グループ(最大20名)</string>
<string name="receipts_groups_enable_for_all">すべてのグループで有効にする</string>
<string name="recipient_colon_delivery_status">%s: %s</string>
<string name="connect_via_member_address_alert_desc">接続リクエストはこのグループ メンバーに送信されます。</string>
<string name="receipts_groups_disable_for_all">すべてのグループで無効にする</string>
<string name="privacy_message_draft">メッセージの下書き</string>
<string name="privacy_show_last_messages">最新のメッセージを表示</string>
<string name="send_receipts">Send receipts</string>
<string name="rcv_group_event_n_members_connected">%s, %s および %d 人の他のメンバーが接続しています。</string>
<string name="rcv_group_event_3_members_connected">%s, %s と %s は接続中</string>
<string name="in_developing_desc">この機能はまだサポートされていません。次のリリースをお試しください。</string>
<string name="receipts_contacts_enable_keep_overrides">有効にする(設定の優先を維持)</string>
<string name="receipts_groups_disable_keep_overrides">無効にする(グループの設定の優先を維持)</string>
<string name="receipts_groups_enable_keep_overrides">有効にする(グループの設定の優先を維持)</string>
<string name="receipts_contacts_disable_keep_overrides">無効にする(設定の優先を維持)</string>
<string name="v5_2_disappear_one_message_descr">会話中に無効になっている場合でも。</string>
<string name="v5_2_message_delivery_receipts">Message delivery receipts!</string>
<string name="delivery_receipts_title">Delivery receipts!</string>
<string name="sending_delivery_receipts_will_be_enabled_all_profiles">Sending delivery receipts will be enabled for all contacts in all visible chat profiles.</string>
<string name="message_delivery_error_title">Message delivery error</string>
<string name="send_receipts_disabled_alert_title">Receipts are disabled</string>
<string name="settings_section_title_delivery_receipts">SEND DELIVERY RECEIPTS TO</string>
<string name="sending_delivery_receipts_will_be_enabled">Sending delivery receipts will be enabled for all contacts.</string>
<string name="send_receipts_disabled_alert_msg">This group has over %1$d members, delivery receipts are not sent.</string>
<string name="error_enabling_delivery_receipts">Error enabling delivery receipts!</string>
<string name="delivery_receipts_are_disabled">Delivery receipts are disabled!</string>
</resources>
@@ -1086,13 +1086,13 @@
<string name="decryption_error">Decodering fout</string>
<string name="alert_text_msg_bad_hash">De hash van het vorige bericht is anders.</string>
<string name="alert_text_decryption_error_too_many_skipped">%1$d berichten overgeslagen.</string>
<string name="alert_text_fragment_encryption_out_of_sync_old_database">Het kan gebeuren wanneer u of uw verbinding de oude databaseback-up gebruikte.</string>
<string name="alert_text_fragment_encryption_out_of_sync_old_database">Het kan gebeuren wanneer u of de ander een oude databaseback-up gebruikt.</string>
<string name="alert_text_fragment_please_report_to_developers">Meld het alsjeblieft aan de ontwikkelaars.</string>
<string name="alert_title_msg_bad_hash">Onjuiste bericht hash</string>
<string name="alert_title_msg_bad_id">Onjuiste bericht-ID</string>
<string name="alert_text_msg_bad_id">De ID van het volgende bericht is onjuist (minder of gelijk aan het vorige).
\nHet kan gebeuren vanwege een bug of wanneer de verbinding is aangetast.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d-berichten konden niet worden ontsleuteld.</string>
<string name="alert_text_decryption_error_n_messages_failed_to_decrypt">%1$d berichten konden niet worden ontsleuteld.</string>
<string name="no_spaces">Geen spaties!</string>
<string name="stop_rcv_file__message">Het ontvangen van het bestand wordt gestopt.</string>
<string name="revoke_file__confirm">Intrekken</string>
@@ -1369,7 +1369,7 @@
<string name="connect_via_member_address_alert_desc">Verzoek voor het verbinden wordt naar dit groepslid verzonden.</string>
<string name="paste_the_link_you_received_to_connect_with_your_contact">Plak de link die je hebt ontvangen om verbinding te maken met je contact…</string>
<string name="system_restricted_background_warn"><![CDATA[Als u meldingen wilt inschakelen, kiest u <b> App-batterijgebruik</b> /<b> Onbeperkt</b> in de app-instellingen.]]></string>
<string name="connect_use_new_incognito_profile">Gebruik een nieuw incognito -profiel</string>
<string name="connect_use_new_incognito_profile">Gebruik een nieuw incognito profiel</string>
<string name="privacy_message_draft">Concept bericht</string>
<string name="rcv_group_event_n_members_connected">%s, %s en %d andere leden verbonden</string>
<string name="privacy_show_last_messages">Laat laatste berichten zien</string>
@@ -414,7 +414,7 @@
<string name="initial_member_role">Função inicial</string>
<string name="snd_group_event_group_profile_updated">perfil do grupo atualizado</string>
<string name="group_member_status_group_deleted">Grupo excluído</string>
<string name="incognito_info_protects">O modo de navegação anônima protege a privacidade do nome e da imagem do seu perfil principal — para cada novo contato, um novo perfil aleatório é criado.</string>
<string name="incognito_info_protects">O modo Incognito protege sua privacidade usando um novo perfil aleatório para cada contato.</string>
<string name="group_members_can_delete">Os membros do grupo podem excluir mensagens enviadas de forma irreversível.</string>
<string name="ttl_w">%dsemana</string>
<string name="v4_3_improved_server_configuration">Configuração de servidor aprimorada</string>
@@ -631,7 +631,7 @@
<string name="images_limit_desc">Apenas 10 imagens podem ser enviadas ao mesmo tempo</string>
<string name="notifications">Notificações</string>
<string name="text_field_set_contact_placeholder">Definir nome do contato…</string>
<string name="switch_receiving_address_desc">Esse recurso é experimental! Ele só funcionará se o outro cliente tiver a versão 4.2 instalada. Você deve ver a mensagem na conversa assim que a alteração de endereço for concluída – verifique se você ainda pode receber mensagens desse contato (ou membro do grupo).</string>
<string name="switch_receiving_address_desc">O endereço de recebimento será alterado para um servidor diferente. A mudança de endereço terminará após o remetente entrar on-line.</string>
<string name="send_verb">Enviar</string>
<string name="reset_verb">Redefinir</string>
<string name="live_message">Mensagem ao vivo!</string>
@@ -1327,4 +1327,28 @@
<string name="enable_receipts_all">Ativar</string>
<string name="sending_delivery_receipts_will_be_enabled">Enviar confirmações de entrega serão ativadas para todos os contatos.</string>
<string name="error_enabling_delivery_receipts">Ocorreu um erro ao ativar as confirmações de entrega!</string>
<string name="choose_file_title">Escolher arquivo</string>
<string name="connect_via_link_incognito">Conectar incógnito</string>
<string name="turn_off_battery_optimization_button">Permitir</string>
<string name="disable_notifications_button">Desativar notificações</string>
<string name="system_restricted_background_in_call_title">Sem chamadas de fundo</string>
<string name="turn_off_system_restriction_button">Abrir configurações do aplicativo</string>
<string name="connect__a_new_random_profile_will_be_shared">Um novo perfil aleatório será compartilhado.</string>
<string name="paste_the_link_you_received_to_connect_with_your_contact">Cole o link que você recebeu para se conectar com seu contato..</string>
<string name="receipts_groups_title_disable">Desativar recibos para grupos\?</string>
<string name="receipts_groups_title_enable">Ativar recibos para grupos\?</string>
<string name="receipts_groups_override_disabled">Recibos de entrega estão desativados para %d grupos</string>
<string name="receipts_groups_enable_for_all">Ativar para todos os grupos</string>
<string name="receipts_groups_enable_keep_overrides">Ativar (manter sobreposições do grupo)</string>
<string name="receipts_groups_disable_keep_overrides">Desativar (manter sobreposições do grupo)</string>
<string name="receipts_groups_disable_for_all">Desativar para todos os grupos</string>
<string name="in_developing_title">Em breve!</string>
<string name="delivery">Entrega</string>
<string name="no_info_on_delivery">Nenhuma informação de entrega</string>
<string name="sending_delivery_receipts_will_be_enabled_all_profiles">Enviar recibos de entrega será ativado para todos os contatos em todos os perfis de chat visíveis.</string>
<string name="rcv_group_event_2_members_connected">%s e %s conectados</string>
<string name="connect_via_member_address_alert_title">Conectar diretamente\?</string>
<string name="no_selected_chat">Sem chat selecionado</string>
<string name="privacy_message_draft">Rascunho de mensagem</string>
<string name="send_receipts_disabled">desativado</string>
</resources>
@@ -104,7 +104,11 @@ object NtfManager {
actions.forEach {
builder.action(it.first, it.second)
}
prevNtfs.add(chatId to builder.toast())
try {
prevNtfs.add(chatId to builder.toast())
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
}
}
private fun prepareIconPath(icon: ImageBitmap?): String? = if (icon != null) {
@@ -25,6 +25,8 @@ fun initApp() {
initChatController()
runMigrations()
}
// LALAL
//testCrypto()
}
private fun applyAppLocale() {
@@ -2,8 +2,10 @@ package chat.simplex.common.platform
import androidx.compose.runtime.*
import chat.simplex.common.*
import chat.simplex.common.views.helpers.AlertManager
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import java.awt.Desktop
import java.io.*
import java.net.URI
@@ -19,6 +21,20 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db"
actual val databaseExportDir: File = tmpDir
actual fun desktopOpenDatabaseDir() {
if (Desktop.isDesktopSupported()) {
try {
Desktop.getDesktop().open(dataDir);
} catch (e: IOException) {
Log.e(TAG, e.stackTraceToString())
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.unknown_error),
text = e.stackTraceToString()
)
}
}
}
@Composable
actual fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any?, onResult: (URI?) -> Unit): FileChooserLauncher =
remember(rememberedValue) { FileChooserLauncher(getContent, onResult) }
@@ -1,7 +1,7 @@
package chat.simplex.common.platform
import androidx.compose.runtime.MutableState
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.*
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import kotlinx.coroutines.CoroutineScope
@@ -18,7 +18,7 @@ actual class RecorderNative: RecorderInterface {
}
actual object AudioPlayer: AudioPlayerInterface {
override fun play(filePath: String?, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, resetOnEnd: Boolean) {
override fun play(fileSource: CryptoFile, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, resetOnEnd: Boolean) {
showInDevelopingAlert()
}
@@ -3,6 +3,8 @@ package chat.simplex.common.platform
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.AnnotatedString
import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.getAppFileUri
import chat.simplex.common.views.helpers.withApi
import java.io.File
import java.net.URI
@@ -20,12 +22,16 @@ actual fun ClipboardManager.shareText(text: String) {
showToast(MR.strings.copied.localized())
}
actual fun shareFile(text: String, filePath: String) {
actual fun shareFile(text: String, fileSource: CryptoFile) {
withApi {
FileChooserLauncher(false) { to: URI? ->
if (to != null) {
copyFileToFile(File(filePath), to) {}
if (fileSource.cryptoArgs != null) {
decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, to.path)
} else {
copyFileToFile(File(fileSource.filePath), to) {}
}
}
}.launch(filePath)
}.launch(fileSource.filePath)
}
}
@@ -11,7 +11,7 @@ import java.net.URI
@Composable
actual fun SimpleAndAnimatedImageView(
uri: URI,
data: ByteArray,
imageBitmap: ImageBitmap,
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
@@ -31,7 +31,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) {
@Composable
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) {
ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = {
ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") }
else -> {}
@@ -4,19 +4,16 @@ import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import chat.simplex.common.platform.VideoPlayer
import chat.simplex.common.views.helpers.getBitmapFromUri
import chat.simplex.common.views.helpers.getBitmapFromByteArray
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import java.net.URI
@Composable
actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) {
actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) {
Image(
getBitmapFromUri(uri, false) ?: MR.images.decentralized.image.toComposeImageBitmap(),
getBitmapFromByteArray(data, false) ?: MR.images.decentralized.image.toComposeImageBitmap(),
contentDescription = stringResource(MR.strings.image_descr),
contentScale = ContentScale.Fit,
modifier = modifier,
@@ -0,0 +1,106 @@
package chat.simplex.common.views.database
import SectionItemView
import SectionTextFooter
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.WarningOrange
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
actual fun SavePassphraseSetting(
useKeychain: Boolean,
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
minHeight: Dp,
onCheckedChange: (Boolean) -> Unit,
) {
SectionItemView(minHeight = minHeight) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (storedKey) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_vpn_key_off_filled),
stringResource(MR.strings.save_passphrase_in_settings),
tint = if (storedKey) WarningOrange else MaterialTheme.colors.secondary
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(MR.strings.save_passphrase_in_settings),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
enabled = !initialRandomDBPassphrase && !progressIndicator
)
}
}
}
@Composable
actual fun DatabaseEncryptionFooter(
useKeychain: MutableState<Boolean>,
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
) {
if (chatDbEncrypted == false) {
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(MR.strings.settings_is_storing_in_clear_text))
if (initialRandomDBPassphrase.value) {
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
} else {
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
}
} else {
SectionTextFooter(generalGetString(MR.strings.passphrase_will_be_saved_in_settings))
}
} else {
SectionTextFooter(generalGetString(MR.strings.you_have_to_enter_passphrase_every_time))
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
}
}
actual fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.encrypt_database_question),
text = generalGetString(MR.strings.database_will_be_encrypted_and_passphrase_stored_in_settings) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(MR.strings.encrypt_database),
onConfirm = onConfirm,
destructive = true,
)
}
actual fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.change_database_passphrase_question),
text = generalGetString(MR.strings.database_encryption_will_be_updated_in_settings) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(MR.strings.update_database),
onConfirm = onConfirm,
destructive = false,
)
}
actual fun removePassphraseAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.remove_passphrase_from_settings),
text = storeSecurelyDanger(),
confirmText = generalGetString(MR.strings.remove_passphrase),
onConfirm = onConfirm,
destructive = true,
)
}
@@ -6,8 +6,10 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import chat.simplex.common.model.CIFile
import chat.simplex.common.model.readCryptoFile
import chat.simplex.common.platform.*
import chat.simplex.common.simplexWindowState
import java.io.ByteArrayInputStream
import java.io.File
import java.net.URI
import javax.imageio.ImageIO
@@ -88,11 +90,12 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat
actual fun getAppFileUri(fileName: String): URI =
URI("file:" + appFilesDir.absolutePath + File.separator + fileName)
actual fun getLoadedImage(file: CIFile?): ImageBitmap? {
actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
val filePath = getLoadedFilePath(file)
return if (filePath != null) {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
getBitmapFromUri(uri, false)
val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes()
val bitmap = getBitmapFromByteArray(data, false)
if (bitmap != null) bitmap to data else null
} else {
null
}
@@ -107,6 +110,9 @@ actual fun getFileSize(uri: URI): Long? = uri.toPath().toFile().length()
actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? =
ImageIO.read(uri.inputStream()).toComposeImageBitmap()
actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? =
ImageIO.read(ByteArrayInputStream(data)).toComposeImageBitmap()
// LALAL implement to support animated drawable
actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? = null