diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index c24ade47d9..ebc1b416b5 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -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, progress: MutableState, duration: MutableState, @@ -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() {} +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index 811974b2d5..a370bbf405 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -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)) } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt index dc8e9dd541..28c00ec018 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt @@ -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 ) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index 15421299a8..8bb70c4a09 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -26,7 +26,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { 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) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt index d23ee58db2..ade538a044 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt @@ -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 ), diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 67c41c3d79..e3c857716d 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -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? { 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()) diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index 7b6c032c8a..eb4714710c 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -1,5 +1,6 @@ #include -//#include +#include +#include //#include //#include @@ -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"), "", "(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; +} diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 8e869ca2d9..ddc5c92f93 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -1,6 +1,7 @@ #include #include #include +#include // 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"), "", "(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; +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index a0120eb96e..fc0867aad6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -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, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt new file mode 100644 index 0000000000..037d27af33 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt @@ -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 = 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) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 612c167bfe..0a178ca2f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index fff77ee23b..d36a6aec16 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 341f4e9548..801a0270e2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -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 +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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index 53b0f8bd96..71a9f204f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -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 * */ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt index bbc5cbe667..2d6bb2a371 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt @@ -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, progress: MutableState, duration: MutableState, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt index 03ad4b5441..72bb3caaac 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index f6e328afdb..c8381cdcb7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1117,7 +1117,7 @@ private fun markUnreadChatAsRead(activeChat: MutableState, 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 -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 01090705d7..4d6bc297f0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -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 "")) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt index 99d7de96be..a4c90d30dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt @@ -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) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 4642600fcb..8de805ba54 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -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) {} + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 75d6a9c304..23d1f1d0cc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -29,6 +29,8 @@ import java.net.URI fun CIImageView( image: String, file: CIFile?, + encryptLocalFile: Boolean, + metaColor: Color, imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, 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 { - val imageBitmap: ImageBitmap? = getLoadedImage(file) - val filePath = getLoadedFilePath(file) - return imageBitmap to filePath + fun imageAndFilePath(file: CIFile?): Triple? { + 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, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index ab121c6272..72f7137b55 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -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 } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index 2918d885b1..8de309fc8f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -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) ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 6ec39bb4f0..941bc315b6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -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 ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index cc2d97e3f5..60ef7e8cfe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 92cf62a855..122e54c3b2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 270c671fc1..9664cabc41 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -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) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 64855e3195..eabab138ba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -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 { " " } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 9ecd7dae31..fa0f8f54d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -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) }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index b9eeee12bc..6aaf7a9fdf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -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? 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 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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index a848d3777b..6632925964 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -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)) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 81d56a3816..ef0940b2a0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -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( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index ac3a68fc49..7929413c93 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -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) }) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 2b83ff869a..ae55ad7e58 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -855,6 +855,7 @@ Privacy & security Your privacy Protect app screen + Encrypt local files Auto-accept images Send link previews Show last messages diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg deleted file mode 100644 index bf6b7b47b4..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg new file mode 100644 index 0000000000..3188cf798e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index 471389d0cd..612217925b 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -25,6 +25,8 @@ fun initApp() { initChatController() runMigrations() } + // LALAL + //testCrypto() } private fun applyAppLocale() { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 2ba6f3b3f6..6e85ea91c6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -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, progress: MutableState, duration: MutableState, resetOnEnd: Boolean) { + override fun play(fileSource: CryptoFile, audioPlaying: MutableState, progress: MutableState, duration: MutableState, resetOnEnd: Boolean) { showInDevelopingAlert() } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt index 84e24a1d55..1d5ab45bbb 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt @@ -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) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 214946b1c9..711e09267d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -11,7 +11,7 @@ import java.net.URI @Composable actual fun SimpleAndAnimatedImageView( - uri: URI, + data: ByteArray, imageBitmap: ImageBitmap, file: CIFile?, imageProvider: () -> ImageGalleryProvider, diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index 9b265a5f5f..c1d9eeec52 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -31,7 +31,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { - 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 -> {} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index e4e483092d..a73c2784ed 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -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, diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index f4a9ac9b78..4fa768a5d3 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -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? { 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