ui: types and stubs to encrypt local files (#3003)

* ui: types and stubs to encrypt local files

* ios: encrypt automatically received images in local storage

* encrypt sent images, marked to be received via NSE

* ios: encrypt sent and received local voice files

* encrypt sent and received local files

* fix NSE

* remove comment

* decrypt files in background thread
This commit is contained in:
Evgeny Poberezkin
2023-09-07 11:28:37 +01:00
committed by GitHub
parent a27f30ce12
commit 748572ace9
36 changed files with 407 additions and 197 deletions
@@ -2024,7 +2024,7 @@ class CIFile(
val fileId: Long,
val fileName: String,
val fileSize: Long,
val filePath: String? = null,
val fileSource: CryptoFile? = null,
val fileStatus: CIFileStatus,
val fileProtocol: FileProtocol
) {
@@ -2072,10 +2072,23 @@ class CIFile(
filePath: String? = "test.txt",
fileStatus: CIFileStatus = CIFileStatus.RcvComplete
): CIFile =
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)
CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, fileSource = if (filePath == null) null else CryptoFile.plain(filePath), fileStatus = fileStatus, fileProtocol = FileProtocol.XFTP)
}
}
@Serializable
class CryptoFile(
val filePath: String,
val cryptoArgs: CryptoFileArgs?
) {
companion object {
fun plain(f: String): CryptoFile = CryptoFile(f, null)
}
}
@Serializable
class CryptoFileArgs(val fileKey: String, val fileNonce: String)
class CancelAction(
val uiActionId: StringResource,
val alert: AlertInfo
@@ -586,7 +586,7 @@ object ChatController {
return null
}
suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? {
suspend fun apiSendMessage(type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? {
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl)
val r = sendCmd(cmd)
return when (r) {
@@ -1079,8 +1079,8 @@ object ChatController {
return false
}
suspend fun apiReceiveFile(fileId: Long, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId, inline))
suspend fun apiReceiveFile(fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
val r = sendCmd(CC.ReceiveFile(fileId, encrypted, inline))
return when (r) {
is CR.RcvFileAccepted -> r.chatItem
is CR.RcvFileAcceptedSndCancelled -> {
@@ -1413,7 +1413,8 @@ object ChatController {
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
withApi { receiveFile(r.user, file.fileId, auto = true) }
// TODO encrypt images and voice
withApi { receiveFile(r.user, file.fileId, encrypted = false, auto = true) }
}
if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
@@ -1647,8 +1648,8 @@ object ChatController {
}
}
suspend fun receiveFile(user: UserLike, fileId: Long, auto: Boolean = false) {
val chatItem = apiReceiveFile(fileId, auto = auto)
suspend fun receiveFile(user: UserLike, fileId: Long, encrypted: Boolean, auto: Boolean = false) {
val chatItem = apiReceiveFile(fileId, encrypted = encrypted, auto = auto)
if (chatItem != null) {
chatItemSimpleUpdate(user, chatItem)
}
@@ -1804,7 +1805,7 @@ sealed class CC {
class ApiGetChats(val userId: Long): CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: CryptoFile?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC()
@@ -1867,7 +1868,7 @@ sealed class CC {
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long, val inline: Boolean?): CC()
class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC()
class CancelFile(val fileId: Long): CC()
class ShowVersion(): CC()
@@ -1972,7 +1973,7 @@ sealed class CC {
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
is ReceiveFile -> if (inline == null) "/freceive $fileId" else "/freceive $fileId inline=${onOff(inline)}"
is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}")
is CancelFile -> "/fcancel $fileId"
is ShowVersion -> "/version"
}
@@ -2134,7 +2135,7 @@ sealed class ChatPagination {
}
@Serializable
class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent)
class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent)
@Serializable
class XFTPFileConfig(val minFileSize: Long)
@@ -62,8 +62,9 @@ fun getAppFilePath(fileName: String): String {
}
fun getLoadedFilePath(file: CIFile?): String? {
return if (file?.filePath != null && file.loaded) {
val filePath = getAppFilePath(file.filePath)
val f = file?.fileSource?.filePath
return if (f != null && file.loaded) {
val filePath = getAppFilePath(f)
if (File(filePath).exists()) filePath else null
} else {
null
@@ -244,8 +244,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
},
receiveFile = { fileId ->
withApi { chatModel.controller.receiveFile(user, fileId) }
receiveFile = { fileId, encrypted ->
withApi { chatModel.controller.receiveFile(user, fileId, encrypted) }
},
cancelFile = { fileId ->
withApi { chatModel.controller.cancelFile(user, fileId) }
@@ -403,7 +403,7 @@ fun ChatLayout(
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
startCall: (CallMediaType) -> Unit,
@@ -656,7 +656,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
@@ -1257,7 +1257,7 @@ fun PreviewChatLayout() {
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
startCall = {},
@@ -1324,7 +1324,7 @@ fun PreviewGroupChatLayout() {
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
startCall = {},
@@ -317,7 +317,7 @@ fun ComposeView(
chatModel.filesToDelete.clear()
}
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false, ttl: Int?): ChatItem? {
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? {
val aChatItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
@@ -331,7 +331,7 @@ fun ComposeView(
chatModel.addChatItem(cInfo, aChatItem.chatItem)
return aChatItem.chatItem
}
if (file != null) removeFile(file)
if (file != null) removeFile(file.filePath)
return null
}
@@ -404,7 +404,7 @@ fun ComposeView(
sent = updateMessage(liveMessage.chatItem, cInfo, live)
} else {
val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<String> = ArrayList()
val files: ArrayList<CryptoFile> = ArrayList()
when (val preview = cs.preview) {
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
@@ -413,7 +413,7 @@ fun ComposeView(
val file = when (it) {
is UploadContent.SimpleImage -> saveImage(it.uri)
is UploadContent.AnimatedImage -> saveAnimImage(it.uri)
is UploadContent.Video -> saveFileFromUri(it.uri)
is UploadContent.Video -> saveFileFromUri(it.uri, encrypted = false)
}
if (file != null) {
files.add(file)
@@ -432,12 +432,13 @@ fun ComposeView(
withContext(Dispatchers.IO) {
Files.move(tmpFile.toPath(), actualFile.toPath())
}
files.add(actualFile.name)
// TODO encrypt voice files
files.add(CryptoFile.plain(actualFile.name))
deleteUnusedFiles()
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
}
is ComposePreview.FilePreview -> {
val file = saveFileFromUri(preview.uri)
val file = saveFileFromUri(preview.uri, encrypted = false)
if (file != null) {
files.add((file))
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
@@ -28,7 +28,7 @@ import java.net.URI
fun CIFileView(
file: CIFile?,
edited: Boolean,
receiveFile: (Long) -> Unit
receiveFile: (Long, Boolean) -> Unit
) {
val saveFileLauncher = rememberSaveFileLauncher(ciFile = file)
@@ -71,7 +71,7 @@ fun CIFileView(
when (file.fileStatus) {
is CIFileStatus.RcvInvitation -> {
if (fileSizeValid()) {
receiveFile(file.fileId)
receiveFile(file.fileId, false)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -31,7 +31,7 @@ fun CIImageView(
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
receiveFile: (Long, Boolean) -> Unit
) {
@Composable
fun progressIndicator() {
@@ -152,7 +152,8 @@ fun CIImageView(
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
if (fileSizeValid()) {
receiveFile(file.fileId)
// TODO encrypt image
receiveFile(file.fileId, false)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -31,7 +31,7 @@ fun CIVideoView(
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit
receiveFile: (Long, Boolean) -> Unit
) {
Box(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
@@ -54,7 +54,7 @@ fun CIVideoView(
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation ->
receiveFileIfValidSize(file, receiveFile)
receiveFileIfValidSize(file, encrypted = false, receiveFile)
CIFileStatus.RcvAccepted ->
when (file.fileProtocol) {
FileProtocol.XFTP ->
@@ -80,7 +80,7 @@ fun CIVideoView(
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
}
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, encrypted = false, receiveFile) }
}
}
}
@@ -301,9 +301,9 @@ private fun fileSizeValid(file: CIFile?): Boolean {
return false
}
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
private fun receiveFileIfValidSize(file: CIFile, encrypted: Boolean, receiveFile: (Long, Boolean) -> Unit) {
if (fileSizeValid(file)) {
receiveFile(file.fileId)
receiveFile(file.fileId, encrypted)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
@@ -37,18 +37,19 @@ fun CIVoiceView(
ci: ChatItem,
timedMessagesTTL: Int?,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
) {
Row(
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = if (hasText) 6.dp else 0.dp, end = if (hasText) 6.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (file != null) {
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(file) }
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) }
val f = file.fileSource?.filePath
val filePath = remember(f, file.fileStatus) { getLoadedFilePath(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
@@ -94,7 +95,7 @@ private fun VoiceLayout(
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
onProgressChanged: (Int) -> Unit,
) {
@Composable
@@ -248,7 +249,7 @@ private fun VoiceMsgIndicator(
play: () -> Unit,
pause: () -> Unit,
longClick: () -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
) {
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
val strokeColor = MaterialTheme.colors.primary
@@ -268,7 +269,8 @@ private fun VoiceMsgIndicator(
}
} else {
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick)
// TODO encrypt voice
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId, false) }, {}, longClick = longClick)
} else if (file?.fileStatus is CIFileStatus.RcvTransfer
|| file?.fileStatus is CIFileStatus.RcvAccepted
) {
@@ -48,7 +48,7 @@ fun ChatItemView(
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
acceptCall: (Contact) -> Unit,
@@ -566,7 +566,7 @@ fun PreviewChatItemView() {
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
acceptCall = { _ -> },
@@ -595,7 +595,7 @@ fun PreviewChatItemViewDeletedContent() {
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
deleteMessage = { _, _ -> },
receiveFile = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
acceptCall = { _ -> },
@@ -36,7 +36,7 @@ fun FramedItemView(
imageProvider: (() -> ImageGalleryProvider)? = null,
linkMode: SimplexLinkMode,
showMenu: MutableState<Boolean>,
receiveFile: (Long) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
onLinkLongClick: (link: String) -> Unit = {},
scrollToItem: (Long) -> Unit = {},
) {
@@ -95,12 +95,13 @@ fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverri
return null
}
fun saveImage(uri: URI): String? {
fun saveImage(uri: URI): CryptoFile? {
val bitmap = getBitmapFromUri(uri) ?: return null
return saveImage(bitmap)
}
fun saveImage(image: ImageBitmap): String? {
fun saveImage(image: ImageBitmap): CryptoFile? {
// TODO encrypt image
return try {
val ext = if (image.hasAlpha()) "png" else "jpg"
val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE)
@@ -110,14 +111,15 @@ fun saveImage(image: ImageBitmap): String? {
dataResized.writeTo(output)
output.flush()
output.close()
fileToSave
CryptoFile.plain(fileToSave)
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveImage error: ${e.stackTraceToString()}")
null
}
}
fun saveAnimImage(uri: URI): String? {
fun saveAnimImage(uri: URI): CryptoFile? {
// TODO encrypt image
return try {
val filename = getFileName(uri)?.lowercase()
var ext = when {
@@ -135,7 +137,7 @@ fun saveAnimImage(uri: URI): String? {
input?.copyTo(output)
}
}
fileToSave
CryptoFile.plain(fileToSave)
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveAnimImage error: ${e.message}")
null
@@ -144,15 +146,16 @@ fun saveAnimImage(uri: URI): String? {
expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File?
fun saveFileFromUri(uri: URI): String? {
fun saveFileFromUri(uri: URI, encrypted: Boolean): CryptoFile? {
return try {
val inputStream = uri.inputStream()
val fileToSave = getFileName(uri)
// TODO encrypt file if "encrypted" is true
if (inputStream != null && fileToSave != null) {
val destFileName = uniqueCombine(fileToSave)
val destFile = File(getAppFilePath(destFileName))
Files.copy(inputStream, destFile.toPath())
destFileName
CryptoFile.plain(destFileName)
} else {
Log.e(TAG, "Util.kt saveFileFromUri null inputStream")
null