multiplatform: local file encryption (#3043)

* multiplatform: file encryption

* setting

* fixed sharing

* check

* fixes, change lock icon

* update JNI bindings (crashes on desktop)

* fix JNI

* fix errors and warnings

* fix saving

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2023-09-10 19:05:12 +03:00
committed by GitHub
parent a87aaa50c7
commit 54e1e10382
43 changed files with 522 additions and 151 deletions
@@ -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;
}
@@ -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
@@ -1394,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
@@ -2077,7 +2085,7 @@ class CIFile(
}
@Serializable
class CryptoFile(
data class CryptoFile(
val filePath: String,
val cryptoArgs: CryptoFileArgs?
) {
@@ -2087,7 +2095,7 @@ class CryptoFile(
}
@Serializable
class CryptoFileArgs(val fileKey: String, val fileNonce: String)
data class CryptoFileArgs(val fileKey: String, val fileNonce: String)
class CancelAction(
val uiActionId: StringResource,
@@ -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"
@@ -1413,8 +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))) {
// TODO encrypt images and voice
withApi { receiveFile(r.user, file.fileId, encrypted = false, 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)
@@ -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
@@ -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.*
@@ -71,6 +72,16 @@ fun getLoadedFilePath(file: CIFile?): String? {
}
}
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
* */
@@ -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)
@@ -1117,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()
}
@@ -1155,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 -> {
@@ -411,8 +411,8 @@ fun ComposeView(
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.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) {
@@ -429,16 +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())
}
// TODO encrypt voice files
files.add(CryptoFile.plain(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, encrypted = false)
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)
}
@@ -71,7 +71,8 @@ fun CIFileView(
when (file.fileStatus) {
is CIFileStatus.RcvInvitation -> {
if (fileSizeValid()) {
receiveFile(file.fileId, false)
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,6 +29,8 @@ import java.net.URI
fun CIImageView(
image: String,
file: CIFile?,
encryptLocalFile: Boolean,
metaColor: Color,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
receiveFile: (Long, Boolean) -> Unit
@@ -48,7 +50,7 @@ fun CIImageView(
icon,
stringResource(stringId),
Modifier.fillMaxSize(),
tint = Color.White
tint = metaColor
)
}
@@ -132,28 +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()) {
// TODO encrypt image
receiveFile(file.fileId, false)
receiveFile(file.fileId, encryptLocalFile)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -187,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)
)
@@ -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
@@ -45,14 +44,16 @@ fun CIVoiceView(
) {
if (file != null) {
val f = file.fileSource?.filePath
val filePath = remember(f, file.fileStatus) { getLoadedFilePath(file) }
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)
@@ -67,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)
@@ -269,8 +270,7 @@ private fun VoiceMsgIndicator(
}
} else {
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
// TODO encrypt voice
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, false) }, {}, 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
) {
@@ -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
@@ -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 {
" "
}
@@ -178,7 +178,7 @@ 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) },
@@ -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,31 +97,34 @@ fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverri
return null
}
fun saveImage(uri: URI): CryptoFile? {
fun saveImage(uri: URI, encrypted: Boolean): CryptoFile? {
val bitmap = getBitmapFromUri(uri) ?: return null
return saveImage(bitmap)
return saveImage(bitmap, encrypted)
}
fun saveImage(image: ImageBitmap): CryptoFile? {
// TODO encrypt image
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()
CryptoFile.plain(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): CryptoFile? {
// TODO encrypt image
fun saveAnimImage(uri: URI, encrypted: Boolean): CryptoFile? {
return try {
val filename = getFileName(uri)?.lowercase()
var ext = when {
@@ -129,15 +134,15 @@ fun saveAnimImage(uri: URI): CryptoFile? {
}
// 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)
}
CryptoFile.plain(fileToSave)
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveAnimImage error: ${e.message}")
null
@@ -150,22 +155,40 @@ fun saveFileFromUri(uri: URI, encrypted: Boolean): CryptoFile? {
return try {
val inputStream = uri.inputStream()
val fileToSave = getFileName(uri)
// TODO encrypt file if "encrypted" is true
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())
CryptoFile.plain(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")
@@ -266,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
@@ -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))
}
}
}
@@ -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(
@@ -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)
})
@@ -855,6 +855,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>
@@ -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