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,6 +1,5 @@
package chat.simplex.common.platform
import android.app.Application
import android.content.Context
import android.media.*
import android.media.AudioManager.AudioPlaybackCallback
@@ -8,10 +7,10 @@ import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
import android.os.Build
import androidx.compose.runtime.*
import chat.simplex.res.MR
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.*
import chat.simplex.common.platform.AudioPlayer.duration
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import java.io.*
@@ -134,20 +133,25 @@ actual object AudioPlayer: AudioPlayerInterface {
}
// Returns real duration of the track
private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
if (!File(filePath).exists()) {
Log.e(TAG, "No such file: $filePath")
private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
val absoluteFilePath = getAppFilePath(fileSource.filePath)
if (!File(absoluteFilePath).exists()) {
Log.e(TAG, "No such file: ${fileSource.filePath}")
return null
}
VideoPlayer.stopAll()
RecorderInterface.stopRecording?.invoke()
val current = currentlyPlaying.value
if (current == null || current.first != filePath) {
if (current == null || current.first != fileSource.filePath) {
stopListener()
player.reset()
runCatching {
player.setDataSource(filePath)
if (fileSource.cryptoArgs != null) {
player.setDataSource(CryptoMediaSource(readCryptoFile(absoluteFilePath, fileSource.cryptoArgs)))
} else {
player.setDataSource(absoluteFilePath)
}
}.onFailure {
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message)
@@ -162,7 +166,7 @@ actual object AudioPlayer: AudioPlayerInterface {
}
if (seek != null) player.seekTo(seek)
player.start()
currentlyPlaying.value = filePath to onProgressUpdate
currentlyPlaying.value = fileSource.filePath to onProgressUpdate
progressJob = CoroutineScope(Dispatchers.Default).launch {
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
while(isActive && player.isPlaying) {
@@ -229,7 +233,7 @@ actual object AudioPlayer: AudioPlayerInterface {
}
override fun play(
filePath: String?,
fileSource: CryptoFile,
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
@@ -238,7 +242,7 @@ actual object AudioPlayer: AudioPlayerInterface {
if (progress.value == duration.value) {
progress.value = 0
}
val realDuration = start(filePath ?: return, progress.value) { pro, state ->
val realDuration = start(fileSource, progress.value) { pro, state ->
if (pro != null) {
progress.value = pro
}
@@ -283,3 +287,21 @@ actual object AudioPlayer: AudioPlayerInterface {
}
actual typealias SoundPlayer = chat.simplex.common.helpers.SoundPlayer
class CryptoMediaSource(val data: ByteArray) : MediaDataSource() {
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
if (position >= data.size) return -1
val endPosition: Int = (position + size).toInt()
var sizeLeft: Int = size
if (endPosition > data.size) {
sizeLeft -= endPosition - data.size
}
System.arraycopy(data, position.toInt(), buffer, offset, sizeLeft)
return sizeLeft
}
override fun getSize(): Long = data.size.toLong()
override fun close() {}
}
@@ -8,13 +8,15 @@ import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
import chat.simplex.common.helpers.toUri
import chat.simplex.common.model.CIFile
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.common.views.helpers.getAppFileUri
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import chat.simplex.common.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import java.io.BufferedOutputStream
import java.io.File
import chat.simplex.res.MR
import java.io.ByteArrayOutputStream
actual fun ClipboardManager.shareText(text: String) {
val sendIntent: Intent = Intent().apply {
@@ -28,9 +30,17 @@ actual fun ClipboardManager.shareText(text: String) {
androidAppContext.startActivity(shareIntent)
}
actual fun shareFile(text: String, filePath: String) {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
val ext = filePath.substringAfterLast(".")
actual fun shareFile(text: String, fileSource: CryptoFile) {
val uri = if (fileSource.cryptoArgs != null) {
val tmpFile = File(tmpDir, fileSource.filePath)
tmpFile.deleteOnExit()
ChatModel.filesToDelete.add(tmpFile)
decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath)
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(tmpFile.absolutePath)).toURI()
} else {
getAppFileUri(fileSource.filePath)
}
val ext = fileSource.filePath.substringAfterLast(".")
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
@@ -84,8 +94,16 @@ fun saveImage(ciFile: CIFile?) {
uri?.let {
androidAppContext.contentResolver.openOutputStream(uri)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
File(filePath).inputStream().use { it.copyTo(outputStream) }
outputStream.close()
if (ciFile.fileSource?.cryptoArgs != null) {
createTmpFileAndDelete { tmpFile ->
decryptCryptoFile(filePath, ciFile.fileSource.cryptoArgs, tmpFile.absolutePath)
tmpFile.inputStream().use { it.copyTo(outputStream) }
}
outputStream.close()
} else {
File(filePath).inputStream().use { it.copyTo(outputStream) }
outputStream.close()
}
showToast(generalGetString(MR.strings.image_saved))
}
}
@@ -19,7 +19,7 @@ import java.net.URI
@Composable
actual fun SimpleAndAnimatedImageView(
uri: URI,
data: ByteArray,
imageBitmap: ImageBitmap,
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
@@ -27,7 +27,7 @@ actual fun SimpleAndAnimatedImageView(
) {
val context = LocalContext.current
val imagePainter = rememberAsyncImagePainter(
ImageRequest.Builder(context).data(data = uri.toUri()).size(coil.size.Size.ORIGINAL).build(),
ImageRequest.Builder(context).data(data = data).size(coil.size.Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil
imageLoader = imageLoader
)
@@ -26,7 +26,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) {
@Composable
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) {
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = {
ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
@@ -26,7 +26,7 @@ import dev.icerock.moko.resources.compose.stringResource
import java.net.URI
@Composable
actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) {
actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) {
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
val imageLoader = ImageLoader.Builder(LocalContext.current)
@@ -40,7 +40,7 @@ actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageB
.build()
Image(
rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current).data(data = uri.toUri()).size(Size.ORIGINAL).build(),
ImageRequest.Builder(LocalContext.current).data(data = data).size(Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil
imageLoader = imageLoader
),
@@ -1,6 +1,5 @@
package chat.simplex.common.views.helpers
import android.app.Application
import android.content.res.Resources
import android.graphics.*
import android.graphics.Typeface
@@ -12,11 +11,8 @@ import android.text.Spanned
import android.text.SpannedString
import android.text.style.*
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.BaselineShift
@@ -159,17 +155,18 @@ actual fun getAppFileUri(fileName: String): URI =
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(getAppFilePath(fileName))).toURI()
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
actual fun getLoadedImage(file: CIFile?): ImageBitmap? {
actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
val filePath = getLoadedFilePath(file)
return if (filePath != null) {
return if (filePath != null && file != null) {
try {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
val parcelFileDescriptor = androidAppContext.contentResolver.openFileDescriptor(uri.toUri(), "r")
val fileDescriptor = parcelFileDescriptor?.fileDescriptor
val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000)
parcelFileDescriptor?.close()
image.asImageBitmap()
val data = if (file.fileSource?.cryptoArgs != null) {
readCryptoFile(getAppFilePath(file.fileSource.filePath), file.fileSource.cryptoArgs)
} else {
File(getAppFilePath(file.fileName)).readBytes()
}
decodeSampledBitmapFromByteArray(data, 1000, 1000).asImageBitmap() to data
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
null
}
} else {
@@ -178,17 +175,17 @@ actual fun getLoadedImage(file: CIFile?): ImageBitmap? {
}
// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap {
private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap {
// First decode with inJustDecodeBounds=true to check dimensions
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
BitmapFactory.decodeByteArray(data, 0, data.size)
// Calculate inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
inJustDecodeBounds = false
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
BitmapFactory.decodeByteArray(data, 0, data.size)
}
}
@@ -254,6 +251,26 @@ actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitma
}?.asImageBitmap()
}
actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? {
return if (Build.VERSION.SDK_INT >= 31) {
val source = ImageDecoder.createSource(data)
try {
ImageDecoder.decodeBitmap(source)
} catch (e: android.graphics.ImageDecoder.DecodeException) {
Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
if (withAlertOnException) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.image_decoding_exception_title),
text = generalGetString(MR.strings.image_decoding_exception_desc)
)
}
null
}
} else {
BitmapFactory.decodeByteArray(data, 0, data.size)
}?.asImageBitmap()
}
actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? {
return if (Build.VERSION.SDK_INT >= 28) {
val source = ImageDecoder.createSource(androidAppContext.contentResolver, uri.toUri())