mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
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:
committed by
GitHub
parent
a87aaa50c7
commit
54e1e10382
+33
-11
@@ -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() {}
|
||||
}
|
||||
|
||||
+27
-9
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
)
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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
|
||||
),
|
||||
|
||||
+32
-15
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user