Make everything more robust

This commit is contained in:
2026-05-25 09:44:40 +03:00
Unverified
parent d9b2545c68
commit 76645e2633
22 changed files with 1035 additions and 69 deletions
@@ -12,6 +12,7 @@ import com.pr0gramm3r101.utils.UtilsLibrary
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.notif_media_upload_channel_name import ru.fromchat.notif_media_upload_channel_name
import ru.fromchat.notif_media_upload_percent
import ru.fromchat.notif_media_upload_progress import ru.fromchat.notif_media_upload_progress
import ru.fromchat.notif_media_upload_text import ru.fromchat.notif_media_upload_text
import ru.fromchat.notif_media_upload_title import ru.fromchat.notif_media_upload_title
@@ -30,9 +31,17 @@ object MediaUploadForegroundHelper {
val defaultText = getString(Res.string.notif_media_upload_text) val defaultText = getString(Res.string.notif_media_upload_text)
ensureChannel(context, channelName) ensureChannel(context, channelName)
val contentText = when { val contentText = when {
percent != null && !filename.isNullOrBlank() -> percent != null -> {
getString(Res.string.notif_media_upload_progress, percent.coerceIn(0, 100), filename) val percentLabel = getString(
percent != null -> "$percent%" Res.string.notif_media_upload_percent,
percent.coerceIn(0, 100),
)
if (!filename.isNullOrBlank()) {
getString(Res.string.notif_media_upload_progress, percentLabel, filename)
} else {
percentLabel
}
}
else -> defaultText else -> defaultText
} }
val builder = NotificationCompat.Builder(context, CHANNEL_ID) val builder = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -0,0 +1,77 @@
package ru.fromchat.ui.chat
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private class CreateFileInDownloadsContract : ActivityResultContract<Pair<String, String>, Uri?>() {
override fun createIntent(context: Context, input: Pair<String, String>): Intent {
val (filename, mimeType) = input
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, filename)
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
runCatching {
putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents",
"primary:Download",
),
)
}
}
}
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
if (resultCode != Activity.RESULT_OK || intent?.data == null) return null
return intent.data
}
}
@Composable
actual fun rememberCreateDownloadDestinationLauncher(
onDestination: (String?) -> Unit,
): (filename: String, mimeType: String) -> Unit {
val launcher = rememberLauncherForActivityResult(CreateFileInDownloadsContract()) { uri ->
if (uri == null) {
onDestination(null)
return@rememberLauncherForActivityResult
}
runCatching {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
UtilsLibrary.context.contentResolver.takePersistableUriPermission(uri, flags)
}
onDestination(uri.toString())
}
return remember(launcher) {
{ filename: String, mimeType: String ->
launcher.launch(filename to mimeType)
}
}
}
suspend fun persistExportUriPermission(exportUri: String) {
withContext(Dispatchers.IO) {
if (!exportUri.startsWith("content://")) return@withContext
runCatching {
val uri = Uri.parse(exportUri)
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
UtilsLibrary.context.contentResolver.takePersistableUriPermission(uri, flags)
}
}
}
@@ -0,0 +1,58 @@
package ru.fromchat.ui.chat
import android.content.Intent
import android.net.Uri
import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
actual suspend fun writeBytesToExportUri(exportUri: String, bytes: ByteArray): Boolean =
withContext(Dispatchers.IO) {
runCatching {
val uri = Uri.parse(exportUri)
UtilsLibrary.context.contentResolver.openOutputStream(uri, "wt")?.use { out ->
out.write(bytes)
} != null
}.getOrDefault(false)
}
actual suspend fun isExportUriAccessible(exportUri: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
when {
exportUri.startsWith("content://") -> {
UtilsLibrary.context.contentResolver
.openFileDescriptor(Uri.parse(exportUri), "r")
?.use { true } == true
}
exportUri.startsWith("file://") -> {
val path = Uri.parse(exportUri).path ?: return@runCatching false
File(path).isFile
}
else -> File(exportUri).isFile
}
}.getOrDefault(false)
}
actual fun openExportUri(exportUri: String, mimeType: String): Boolean {
val context = UtilsLibrary.context
val uri = Uri.parse(
when {
exportUri.startsWith("content://") || exportUri.startsWith("file://") -> exportUri
else -> "file://$exportUri"
},
)
if (uri.scheme == "file") {
val path = uri.path ?: return false
if (!File(path).isFile) return false
}
return runCatching {
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(Intent.createChooser(intent, null))
true
}.getOrDefault(false)
}
@@ -82,7 +82,8 @@
<string name="action_delete">Удалить</string> <string name="action_delete">Удалить</string>
<string name="action_copy">Копировать</string> <string name="action_copy">Копировать</string>
<string name="action_cancel_send">Отменить</string> <string name="action_cancel_send">Отменить</string>
<string name="notif_media_upload_progress">%1$d%% · %2$s</string> <string name="notif_media_upload_percent">%1$d\u0025</string>
<string name="notif_media_upload_progress">%1$s · %2$s</string>
<string name="action_save">Сохранить</string> <string name="action_save">Сохранить</string>
<string name="cd_close">Закрыть</string> <string name="cd_close">Закрыть</string>
<string name="cd_remove">Убрать</string> <string name="cd_remove">Убрать</string>
@@ -98,7 +98,8 @@
<string name="action_copy">Copy</string> <string name="action_copy">Copy</string>
<string name="action_cancel_send">Cancel</string> <string name="action_cancel_send">Cancel</string>
<string name="action_save">Save</string> <string name="action_save">Save</string>
<string name="notif_media_upload_progress">%1$d%% · %2$s</string> <string name="notif_media_upload_percent">%1$d\u0025</string>
<string name="notif_media_upload_progress">%1$s · %2$s</string>
<!-- Chat input --> <!-- Chat input -->
<string name="cd_close">Close</string> <string name="cd_close">Close</string>
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.ui.chat.AttachmentMediaLog import ru.fromchat.ui.chat.AttachmentMediaLog
import ru.fromchat.ui.chat.DecryptedImageCache import ru.fromchat.ui.chat.DecryptedImageCache
import ru.fromchat.ui.chat.DownloadedFileRegistry
sealed class AttachmentDownloadProgress { sealed class AttachmentDownloadProgress {
data class InProgress(val storageKey: String, val percent: Int) : AttachmentDownloadProgress() data class InProgress(val storageKey: String, val percent: Int) : AttachmentDownloadProgress()
@@ -44,6 +45,7 @@ object AttachmentDownloadNotifier {
messageId: Int = 0, messageId: Int = 0,
fileIndex: Int = 0, fileIndex: Int = 0,
clientMessageId: String? = null, clientMessageId: String? = null,
mirrorAsFileAttachment: Boolean = false,
) { ) {
val msg = AttachmentMediaLog.messageLabel(messageLabel) val msg = AttachmentMediaLog.messageLabel(messageLabel)
val primaryKey = when (progress) { val primaryKey = when (progress) {
@@ -51,11 +53,12 @@ object AttachmentDownloadNotifier {
is AttachmentDownloadProgress.Success -> progress.storageKey is AttachmentDownloadProgress.Success -> progress.storageKey
is AttachmentDownloadProgress.Failed -> progress.storageKey is AttachmentDownloadProgress.Failed -> progress.storageKey
} }
val mirrorKeys = DecryptedImageCache.progressLookupKeys( val mirrorKeys = when {
messageId = messageId, mirrorAsFileAttachment || primaryKey.startsWith("file_") ->
fileIndex = fileIndex, DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
clientMessageId = clientMessageId, else ->
).ifEmpty { listOf(primaryKey) } DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
}.ifEmpty { listOf(primaryKey) }
when (progress) { when (progress) {
is AttachmentDownloadProgress.InProgress -> { is AttachmentDownloadProgress.InProgress -> {
if (progress.percent == 1 || progress.percent % 15 == 0 || progress.percent >= 95) { if (progress.percent == 1 || progress.percent % 15 == 0 || progress.percent >= 95) {
@@ -105,13 +108,32 @@ object AttachmentDownloadNotifier {
} }
} }
fun clearProgress(messageId: Int, fileIndex: Int, clientMessageId: String? = null) { fun clearProgress(
val keys = DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId).toSet() messageId: Int,
fileIndex: Int,
clientMessageId: String? = null,
mirrorAsFileAttachment: Boolean = false,
) {
val keys = if (mirrorAsFileAttachment) {
DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
} else {
DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
}.toSet()
_progressPercentByKey.update { map -> map - keys } _progressPercentByKey.update { map -> map - keys }
_failedKeys.update { failed -> failed - keys } _failedKeys.update { failed -> failed - keys }
} }
fun isFailed(messageId: Int, fileIndex: Int, clientMessageId: String? = null): Boolean = fun isFailed(
messageId: Int,
fileIndex: Int,
clientMessageId: String? = null,
mirrorAsFileAttachment: Boolean = false,
): Boolean {
val keys = if (mirrorAsFileAttachment) {
DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
} else {
DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId) DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
.any { it in _failedKeys.value } }
return keys.any { it in _failedKeys.value }
}
} }
@@ -17,6 +17,14 @@ import ru.fromchat.ui.chat.DecryptedImageCache
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Serializable
private data class PersistedOptimisticOutboundPayload(
@SerialName("text") val text: String,
@SerialName("pendingFileUri") val pendingFileUri: String? = null,
@SerialName("pendingFilename") val pendingFilename: String? = null,
@SerialName("uploadJobId") val uploadJobId: String? = null,
)
@Serializable @Serializable
private data class PersistedDmMessagePayload( private data class PersistedDmMessagePayload(
@SerialName("text") val text: String, @SerialName("text") val text: String,
@@ -39,8 +47,25 @@ data class ParsedDmMessageContent(
val fileDimensions: List<Pair<Int, Int>>? = null, val fileDimensions: List<Pair<Int, Int>>? = null,
val isContentCorrupted: Boolean = false, val isContentCorrupted: Boolean = false,
val localPreviewUri: String? = null, val localPreviewUri: String? = null,
val pendingFileUri: String? = null,
val pendingFilename: String? = null,
val uploadJobId: String? = null,
) )
/** Persists in-flight attachment fields so SQLDelight reload keeps the file row UI. */
fun encodeOptimisticOutboundMessage(message: Message): String {
val pendingUri = message.pendingFileUri?.trim().orEmpty()
if (pendingUri.isEmpty()) return message.content
return json.encodeToString(
PersistedOptimisticOutboundPayload(
text = message.content,
pendingFileUri = pendingUri,
pendingFilename = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() },
uploadJobId = message.uploadJobId?.trim()?.takeIf { it.isNotEmpty() },
),
)
}
fun resolveLocalPreviewUri(message: Message): String? { fun resolveLocalPreviewUri(message: Message): String? {
message.pendingFileUri?.takeIf { uri -> message.pendingFileUri?.takeIf { uri ->
DecryptedImageCache.isDecryptedImageCacheUri(uri) && localPreviewFileExists(uri) DecryptedImageCache.isDecryptedImageCacheUri(uri) && localPreviewFileExists(uri)
@@ -106,9 +131,21 @@ fun encodePersistedDmMessage(message: Message): String {
fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent { fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent {
val trimmed = plaintext.trim() val trimmed = plaintext.trim()
if (trimmed.startsWith("{")) { if (trimmed.startsWith("{")) {
val isPersistedEnvelope = runCatching { val root = runCatching { json.parseToJsonElement(trimmed).jsonObject }.getOrNull()
json.parseToJsonElement(trimmed).jsonObject.containsKey("envelope") if (root?.containsKey("pendingFileUri") == true) {
}.getOrDefault(false) return runCatching {
val payload = json.decodeFromString<PersistedOptimisticOutboundPayload>(trimmed)
ParsedDmMessageContent(
text = payload.text,
pendingFileUri = payload.pendingFileUri?.takeIf { it.isNotBlank() },
pendingFilename = payload.pendingFilename?.takeIf { it.isNotBlank() },
uploadJobId = payload.uploadJobId?.takeIf { it.isNotBlank() },
)
}.getOrElse {
ParsedDmMessageContent(text = plaintext)
}
}
val isPersistedEnvelope = root?.containsKey("envelope") == true
if (isPersistedEnvelope) { if (isPersistedEnvelope) {
return runCatching { return runCatching {
val payload = json.decodeFromString<PersistedDmMessagePayload>(trimmed) val payload = json.decodeFromString<PersistedDmMessagePayload>(trimmed)
@@ -327,7 +327,7 @@ object MessageCacheStore {
id = msg.id.toLong(), id = msg.id.toLong(),
conversationId = conversationId, conversationId = conversationId,
userId = msg.user_id.toLong(), userId = msg.user_id.toLong(),
content = msg.content, content = storedMessageContent(msg),
timestamp = msg.timestamp, timestamp = msg.timestamp,
isRead = if (msg.is_read) 1L else 0L, isRead = if (msg.is_read) 1L else 0L,
isEdited = if (msg.is_edited) 1L else 0L, isEdited = if (msg.is_edited) 1L else 0L,
@@ -533,7 +533,11 @@ object MessageCacheStore {
isContentCorrupted = parsed.isContentCorrupted, isContentCorrupted = parsed.isContentCorrupted,
) )
return base.copy( return base.copy(
pendingFileUri = parsed.localPreviewUri ?: resolveLocalPreviewUri(base), pendingFileUri = parsed.pendingFileUri
?: parsed.localPreviewUri
?: resolveLocalPreviewUri(base),
pendingFilename = parsed.pendingFilename ?: base.pendingFilename,
uploadJobId = parsed.uploadJobId ?: base.uploadJobId,
pendingFileAspectRatio = parsed.fileAspectRatios?.firstOrNull() pendingFileAspectRatio = parsed.fileAspectRatios?.firstOrNull()
?: parsed.fileDimensions?.firstOrNull()?.let { (w, h) -> ?: parsed.fileDimensions?.firstOrNull()?.let { (w, h) ->
aspectRatioFromDimensionPair(w, h) aspectRatioFromDimensionPair(w, h)
@@ -541,6 +545,12 @@ object MessageCacheStore {
) )
} }
private fun storedMessageContent(msg: Message): String = when {
msg.id < 0 -> encodeOptimisticOutboundMessage(msg)
!msg.files.isNullOrEmpty() && msg.dmEnvelope != null -> encodePersistedDmMessage(msg)
else -> msg.content
}
suspend fun clearAll() { suspend fun clearAll() {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.purgeAllCache() db.messageDatabaseQueries.purgeAllCache()
@@ -572,12 +582,7 @@ object MessageCacheStore {
id = msg.id.toLong(), id = msg.id.toLong(),
conversationId = conversationId, conversationId = conversationId,
userId = msg.user_id.toLong(), userId = msg.user_id.toLong(),
content = when { content = storedMessageContent(msg),
msg.id < 0 -> msg.content
!msg.files.isNullOrEmpty() && msg.dmEnvelope != null ->
encodePersistedDmMessage(msg)
else -> msg.content
},
timestamp = msg.timestamp, timestamp = msg.timestamp,
isRead = if (msg.is_read) 1L else 0L, isRead = if (msg.is_read) 1L else 0L,
isEdited = if (msg.is_edited) 1L else 0L, isEdited = if (msg.is_edited) 1L else 0L,
@@ -0,0 +1,12 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
/**
* Opens the platform "save as" UI (default Downloads). Invokes [onDestination] with a
* persistent export URI string, or null if cancelled.
*/
@Composable
expect fun rememberCreateDownloadDestinationLauncher(
onDestination: (String?) -> Unit,
): (filename: String, mimeType: String) -> Unit
@@ -0,0 +1,27 @@
package ru.fromchat.ui.chat
fun mimeTypeForFilename(filename: String): String {
val ext = filename.substringAfterLast('.').lowercase()
return when (ext) {
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"gif" -> "image/gif"
"webp" -> "image/webp"
"heic", "heif" -> "image/heic"
"bmp" -> "image/bmp"
"pdf" -> "application/pdf"
"zip" -> "application/zip"
"txt" -> "text/plain"
"json" -> "application/json"
"mp4" -> "video/mp4"
"mp3" -> "audio/mpeg"
"wav" -> "audio/wav"
"doc" -> "application/msword"
"docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
"xls" -> "application/vnd.ms-excel"
"xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
"ppt" -> "application/vnd.ms-powerpoint"
"pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation"
else -> "application/octet-stream"
}
}
@@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AttachFile import androidx.compose.material.icons.rounded.AttachFile
import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.InsertDriveFile
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -113,7 +114,6 @@ fun AttachmentPreview(
messageId: Int? = null, messageId: Int? = null,
fileIndex: Int? = null, fileIndex: Int? = null,
clientMessageId: String? = null, clientMessageId: String? = null,
onFileClick: (() -> Unit)? = null,
onImageClick: (() -> Unit)? = null, onImageClick: (() -> Unit)? = null,
onImageBounds: ((Rect) -> Unit)? = null, onImageBounds: ((Rect) -> Unit)? = null,
isExpanded: Boolean = false, isExpanded: Boolean = false,
@@ -139,7 +139,7 @@ fun AttachmentPreview(
when { when {
(file != null && !isImage) || isPendingFile -> { (file != null && !isImage) || isPendingFile -> {
ExpressiveFileAttachmentRow( ChatFileAttachmentTile(
filename = file?.name filename = file?.name
?: pendingFilename?.takeIf { it.isNotBlank() } ?: pendingFilename?.takeIf { it.isNotBlank() }
?: pendingFileUri?.substringAfterLast("/") ?: pendingFileUri?.substringAfterLast("/")
@@ -147,11 +147,18 @@ fun AttachmentPreview(
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?: "File", ?: "File",
sizeBytes = fileSizeBytes, sizeBytes = fileSizeBytes,
onClick = if (file != null) onFileClick else null, messageId = messageId ?: -1,
fileIndex = fileIndex ?: 0,
clientMessageId = clientMessageId,
file = file,
dmEnvelope = dmEnvelope,
currentUserId = currentUserId,
pendingFileUri = if (isPendingFile) pendingFileUri else null,
isAuthor = isAuthor, isAuthor = isAuthor,
isUploading = isPendingFile && (isUploading || awaitingServerAck), isUploading = isPendingFile && (isUploading || awaitingServerAck),
uploadProgress = if (isPendingFile) uploadProgress else null, uploadProgress = if (isPendingFile) uploadProgress else null,
modifier = modifier messageLabel = messageLabel,
modifier = modifier,
) )
} }
showImageTile -> { showImageTile -> {
@@ -962,13 +969,14 @@ private fun formatFileSize(bytes: Long): String {
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun ExpressiveFileAttachmentRow( internal fun ExpressiveFileAttachmentRow(
filename: String, filename: String,
sizeBytes: Long?, sizeBytes: Long?,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
isAuthor: Boolean, isAuthor: Boolean,
isUploading: Boolean, isUploading: Boolean,
uploadProgress: Int?, uploadProgress: Int?,
isDownloaded: Boolean = false,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
@@ -1022,7 +1030,11 @@ private fun ExpressiveFileAttachmentRow(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Download, imageVector = if (isDownloaded) {
Icons.Rounded.InsertDriveFile
} else {
Icons.Rounded.Download
},
contentDescription = null, contentDescription = null,
modifier = Modifier.size(26.dp), modifier = Modifier.size(26.dp),
tint = if (isAuthor) { tint = if (isAuthor) {
@@ -0,0 +1,211 @@
package ru.fromchat.ui.chat
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.InsertDriveFile
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.fromchat.api.AttachmentDownloadNotifier
import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChatFileAttachmentTile(
filename: String,
sizeBytes: Long?,
messageId: Int,
fileIndex: Int,
clientMessageId: String?,
file: DmFile?,
dmEnvelope: DmEnvelope?,
currentUserId: Int?,
pendingFileUri: String?,
isAuthor: Boolean,
isUploading: Boolean,
uploadProgress: Int?,
messageLabel: String? = null,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val isPendingLocal = pendingFileUri != null && file == null
val mimeType = remember(filename) { mimeTypeForFilename(filename) }
var exportUri by remember(messageId, fileIndex, clientMessageId) {
mutableStateOf<String?>(null)
}
var uriAccessible by remember { mutableStateOf(false) }
LaunchedEffect(messageId, fileIndex, clientMessageId) {
val stored = DownloadedFileRegistry.getExportUri(messageId, fileIndex, clientMessageId)
exportUri = stored
uriAccessible = stored != null && isExportUriAccessible(stored)
if (stored != null && !uriAccessible) {
DownloadedFileRegistry.removeExportUri(messageId, fileIndex, clientMessageId)
exportUri = null
AttachmentDownloadNotifier.clearProgress(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
}
}
val downloadProgressByKey by AttachmentDownloadNotifier.progressPercentByKey.collectAsState()
val downloadProgress = remember(downloadProgressByKey, messageId, fileIndex, clientMessageId) {
DownloadedFileRegistry.resolveDownloadPercent(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
progressByKey = downloadProgressByKey,
)
}
val isDownloading = !isUploading &&
downloadProgress != null &&
downloadProgress < 100
var pendingDownload by remember { mutableStateOf<PendingFileDownload?>(null) }
val launchDestinationPicker = rememberCreateDownloadDestinationLauncher { destination ->
val pending = pendingDownload
pendingDownload = null
if (destination == null || pending == null) return@rememberCreateDownloadDestinationLauncher
scope.launch {
val ok = DmFileDownloader.downloadToExportUri(
messageId = pending.messageId,
fileIndex = pending.fileIndex,
file = pending.file,
envelope = pending.envelope,
currentUserId = pending.currentUserId,
clientMessageId = pending.clientMessageId,
exportUri = destination,
messageLabel = pending.messageLabel,
)
if (ok) {
uriAccessible = isExportUriAccessible(destination)
if (!uriAccessible) {
DownloadedFileRegistry.removeExportUri(
pending.messageId,
pending.fileIndex,
pending.clientMessageId,
)
exportUri = null
}
} else {
exportUri = null
uriAccessible = false
}
}
}
val isDownloaded = !isPendingLocal && uriAccessible && exportUri != null && !isDownloading
val showWavy = isUploading || isDownloading
val onRowClick: (() -> Unit)? = when {
isUploading -> null
isPendingLocal && pendingFileUri != null -> {
{
scope.launch {
withContext(Dispatchers.Default) {
openExportUri(pendingFileUri, mimeType)
}
}
}
}
isDownloaded && exportUri != null -> {
{
scope.launch {
val accessible = isExportUriAccessible(exportUri!!)
if (!accessible) {
DownloadedFileRegistry.removeExportUri(messageId, fileIndex, clientMessageId)
exportUri = null
uriAccessible = false
AttachmentDownloadNotifier.clearProgress(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
return@launch
}
withContext(Dispatchers.Default) {
if (!openExportUri(exportUri!!, mimeType)) {
DownloadedFileRegistry.removeExportUri(messageId, fileIndex, clientMessageId)
exportUri = null
uriAccessible = false
}
}
}
}
}
file != null && dmEnvelope != null && !isDownloading -> {
{
pendingDownload = PendingFileDownload(
messageId = messageId,
fileIndex = fileIndex,
file = file,
envelope = dmEnvelope,
currentUserId = currentUserId,
clientMessageId = clientMessageId,
messageLabel = messageLabel,
)
launchDestinationPicker(filename, mimeType)
}
}
else -> null
}
ExpressiveFileAttachmentRow(
filename = filename,
sizeBytes = sizeBytes,
onClick = onRowClick,
isAuthor = isAuthor,
isUploading = showWavy,
uploadProgress = when {
isUploading -> uploadProgress
isDownloading -> downloadProgress
else -> null
},
isDownloaded = isDownloaded,
modifier = modifier,
)
}
private data class PendingFileDownload(
val messageId: Int,
val fileIndex: Int,
val file: DmFile,
val envelope: DmEnvelope,
val currentUserId: Int?,
val clientMessageId: String?,
val messageLabel: String?,
)
@@ -403,12 +403,12 @@ fun ChatScreen(
contextMenuState = contextMenuState.copy(isOpen = false, message = null) contextMenuState = contextMenuState.copy(isOpen = false, message = null)
return@LaunchedEffect return@LaunchedEffect
} }
val menuAuthor = menuMessage.user_id == currentUserId
val liveAuthor = liveMessage.user_id == currentUserId val liveAuthor = liveMessage.user_id == currentUserId
val menuFp = messageContextMenuFingerprint(menuMessage, menuAuthor, isReadOnly)
val liveFp = messageContextMenuFingerprint(liveMessage, liveAuthor, isReadOnly) val liveFp = messageContextMenuFingerprint(liveMessage, liveAuthor, isReadOnly)
if (menuFp != liveFp) { val menuAuthor = menuMessage.user_id == currentUserId
contextMenuState = contextMenuState.copy(isOpen = false, message = null) val menuFp = messageContextMenuFingerprint(menuMessage, menuAuthor, isReadOnly)
if (menuFp != liveFp || liveMessage.id != menuMessage.id) {
contextMenuState = contextMenuState.copy(message = liveMessage)
} }
} }
@@ -502,7 +502,10 @@ fun ChatScreen(
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
) )
} else { } else {
null prepareOutboundFileForSend(
clientMessageId = jobId,
sourceUri = att.uri,
)
} }
val fileUri = staged?.stagedUri ?: att.uri val fileUri = staged?.stagedUri ?: att.uri
val optimisticMessage = Message( val optimisticMessage = Message(
@@ -522,14 +525,14 @@ fun ChatScreen(
pendingFileUri = fileUri, pendingFileUri = fileUri,
pendingFilename = att.filename, pendingFilename = att.filename,
uploadJobId = jobId, uploadJobId = jobId,
uploadProgress = if (isImage) 0 else null, uploadProgress = 0,
pendingFileAspectRatio = staged?.aspectRatio ?: aspectRatio, pendingFileAspectRatio = staged?.aspectRatio ?: aspectRatio,
fileDimensions = imageDimensions?.let { listOf(it) }, fileDimensions = imageDimensions?.let { listOf(it) },
) )
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
panel.addMessage(optimisticMessage) panel.addMessage(optimisticMessage)
} }
if (isImage && staged == null) { if (staged == null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
panel.cancelQueuedMessageByClientId(jobId) panel.cancelQueuedMessageByClientId(jobId)
} }
@@ -626,7 +629,15 @@ fun ChatScreen(
message = message, message = message,
isAuthor = message.user_id == currentUserId, isAuthor = message.user_id == currentUserId,
isContextMenuOpen = contextMenuState.isOpen, isContextMenuOpen = contextMenuState.isOpen,
isContextMenuForThisMessage = contextMenuState.isOpen && contextMenuState.message?.id == message.id, isContextMenuForThisMessage = contextMenuState.isOpen && run {
val menu = contextMenuState.message ?: return@run false
val cid = menu.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty()) {
message.client_message_id?.trim() == cid
} else {
menu.id == message.id
}
},
onLongPress = { onLongPress = {
if (isReadOnly) { if (isReadOnly) {
return@MessageItem return@MessageItem
@@ -0,0 +1,112 @@
package ru.fromchat.ui.chat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.AttachmentDownloadNotifier
import ru.fromchat.api.AttachmentDownloadProgress
import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile
import ru.fromchat.crypto.decryptFile
object DmFileDownloader {
suspend fun downloadToExportUri(
messageId: Int,
fileIndex: Int,
file: DmFile,
envelope: DmEnvelope,
currentUserId: Int?,
clientMessageId: String?,
exportUri: String,
messageLabel: String? = null,
): Boolean {
val key = DownloadedFileRegistry.storageKey(messageId, fileIndex, clientMessageId)
val label = AttachmentMediaLog.messageLabel(messageLabel)
return withContext(Dispatchers.Default + NonCancellable) {
runCatching {
val written = AttachmentDownloadScheduler.run(storageKey = key, messageId = messageId) {
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.InProgress(key, 1),
messageLabel = label,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
val bytes = decryptFile(
file = file,
envelope = envelope,
currentUserId = currentUserId,
downloadResumeKey = key,
onDownloadProgress = { percent ->
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.InProgress(key, percent.coerceIn(0, 100)),
messageLabel = label,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
},
)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.InProgress(key, 99),
messageLabel = label,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
if (!writeBytesToExportUri(exportUri, bytes)) {
ApiClient.clearPartialEncryptedDownload(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(key, "write_failed"),
messageLabel = label,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
return@run null
}
DownloadedFileRegistry.setExportUri(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
exportUri = exportUri,
)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Success(storageKey = key, messageId = messageId),
messageLabel = label,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
exportUri
}
written != null
}.onFailure { error ->
ApiClient.clearPartialEncryptedDownload(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(
storageKey = key,
error = error.message ?: "download_failed",
),
messageLabel = label,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
}.getOrDefault(false)
}
}
}
expect suspend fun writeBytesToExportUri(exportUri: String, bytes: ByteArray): Boolean
expect suspend fun isExportUriAccessible(exportUri: String): Boolean
expect fun openExportUri(exportUri: String, mimeType: String): Boolean
@@ -0,0 +1,158 @@
package ru.fromchat.ui.chat
import com.pr0gramm3r101.utils.files.PlatformFileSystem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import ru.fromchat.core.cache.CacheContext
import ru.fromchat.core.cache.readOutboundFileBytes
/**
* Maps DM file attachment slots to a user-chosen export URI (SAF / document picker).
* Does not store file bytes only the destination the user selected.
*/
object DownloadedFileRegistry {
private const val INDEX_FILE = "downloaded_exports.json"
private val json = Json { ignoreUnknownKeys = true }
private val mutex = Mutex()
private val memory = mutableMapOf<String, String>()
private var diskIndexLoaded = false
fun storageKey(
messageId: Int,
fileIndex: Int,
clientMessageId: String? = null,
): String {
if (messageId > 0) return "file_${messageId}_$fileIndex"
val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
return if (cid != null) {
"file_c_${sanitizeKeyPart(cid)}_$fileIndex"
} else {
"file_${messageId}_$fileIndex"
}
}
fun progressLookupKeys(
messageId: Int,
fileIndex: Int,
clientMessageId: String? = null,
): List<String> = buildList {
add(storageKey(messageId, fileIndex, clientMessageId))
if (messageId > 0) add("file_${messageId}_$fileIndex")
val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
if (cid != null) add("file_c_${sanitizeKeyPart(cid)}_$fileIndex")
}.distinct()
fun resolveDownloadPercent(
messageId: Int,
fileIndex: Int,
clientMessageId: String? = null,
progressByKey: Map<String, Int> = emptyMap(),
): Int? {
for (lookupKey in progressLookupKeys(messageId, fileIndex, clientMessageId)) {
progressByKey[lookupKey]?.let { return it }
}
return null
}
suspend fun getExportUri(
messageId: Int,
fileIndex: Int,
clientMessageId: String? = null,
): String? {
ensureDiskIndexLoaded()
return mutex.withLock {
memory[storageKey(messageId, fileIndex, clientMessageId)]
}
}
suspend fun setExportUri(
messageId: Int,
fileIndex: Int,
clientMessageId: String? = null,
exportUri: String,
) {
ensureDiskIndexLoaded()
mutex.withLock {
val key = storageKey(messageId, fileIndex, clientMessageId)
memory[key] = exportUri
saveIndexLocked(memory)
}
}
suspend fun removeExportUri(
messageId: Int,
fileIndex: Int,
clientMessageId: String? = null,
) {
ensureDiskIndexLoaded()
mutex.withLock {
val key = storageKey(messageId, fileIndex, clientMessageId)
memory.remove(key)
saveIndexLocked(memory)
}
}
suspend fun invalidateForMessage(messageId: Int) {
ensureDiskIndexLoaded()
mutex.withLock {
val prefix = "file_${messageId}_"
memory.keys.removeAll { it.startsWith(prefix) }
saveIndexLocked(memory)
}
}
suspend fun invalidateForClientMessage(clientMessageId: String) {
ensureDiskIndexLoaded()
mutex.withLock {
val prefix = "file_c_${sanitizeKeyPart(clientMessageId.trim())}_"
memory.keys.removeAll { it.startsWith(prefix) }
saveIndexLocked(memory)
}
}
private suspend fun ensureDiskIndexLoaded() {
if (diskIndexLoaded) return
val index = withContext(Dispatchers.Default) { readIndexFromDisk() }
mutex.withLock {
if (!diskIndexLoaded) {
memory.putAll(index)
diskIndexLoaded = true
}
}
}
private fun sanitizeKeyPart(value: String): String =
value.replace(Regex("[^a-zA-Z0-9._-]"), "_")
private fun indexPath(): String? {
val base = PlatformFileSystem.getAppCacheDirectory()
if (base.isEmpty()) return null
val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default"
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val dir = "$base/fromchat/instances/$safe"
PlatformFileSystem.ensureDirectory(dir)
return "$dir/$INDEX_FILE"
}
private suspend fun readIndexFromDisk(): Map<String, String> {
val path = indexPath() ?: return emptyMap()
if (!PlatformFileSystem.exists(path)) return emptyMap()
val bytes = runCatching {
readOutboundFileBytes("file://$path")
}.getOrNull() ?: return emptyMap()
if (bytes.isEmpty()) return emptyMap()
return runCatching {
json.decodeFromString<Map<String, String>>(bytes.decodeToString())
}.getOrDefault(emptyMap())
}
private fun saveIndexLocked(index: Map<String, String>) {
val path = indexPath() ?: return
val bytes = json.encodeToString(index).encodeToByteArray()
PlatformFileSystem.writeBytes(path, bytes)
}
}
@@ -16,17 +16,7 @@ data class SavableMessageImage(
val mimeType: String, val mimeType: String,
) )
fun mimeTypeForImageFilename(filename: String): String { fun mimeTypeForImageFilename(filename: String): String = mimeTypeForFilename(filename)
val ext = filename.substringAfterLast('.').lowercase()
return when (ext) {
"png" -> "image/png"
"gif" -> "image/gif"
"webp" -> "image/webp"
"heic", "heif" -> "image/heic"
"bmp" -> "image/bmp"
else -> "image/jpeg"
}
}
/** Local decrypted (or staged) image ready to copy to user storage. */ /** Local decrypted (or staged) image ready to copy to user storage. */
fun isMessageImageFullyLoaded(message: Message, fileIndex: Int): Boolean { fun isMessageImageFullyLoaded(message: Message, fileIndex: Int): Boolean {
@@ -202,6 +202,9 @@ fun MessageItem(
) )
else -> false else -> false
} }
val pendingHasOutboundFile = message.pendingFileUri != null &&
message.files.isNullOrEmpty() &&
!pendingIsImage
val firstContentIsImage = ( val firstContentIsImage = (
!showUsername || isAuthor !showUsername || isAuthor
) && message.reply_to == null && ( ) && message.reply_to == null && (
@@ -418,6 +421,8 @@ fun MessageItem(
val primaryFile = message.files?.firstOrNull() val primaryFile = message.files?.firstOrNull()
val primaryIsImage = primaryFile != null && isImageFilename(primaryFile.name) val primaryIsImage = primaryFile != null && isImageFilename(primaryFile.name)
val showPrimaryImageSlot = pendingIsImage || primaryIsImage val showPrimaryImageSlot = pendingIsImage || primaryIsImage
val showPrimaryFileSlot = pendingHasOutboundFile ||
(primaryFile != null && !primaryIsImage)
if (showPrimaryImageSlot) { if (showPrimaryImageSlot) {
val imageKey = imageAttachmentKey(message, 0) val imageKey = imageAttachmentKey(message, 0)
val awaitingServer = message.id < 0 && message.files.isNullOrEmpty() val awaitingServer = message.id < 0 && message.files.isNullOrEmpty()
@@ -448,7 +453,6 @@ fun MessageItem(
messageId = message.id, messageId = message.id,
fileIndex = 0, fileIndex = 0,
clientMessageId = message.client_message_id, clientMessageId = message.client_message_id,
onFileClick = null,
onImageClick = { onImageClick?.invoke(message, 0) }, onImageClick = { onImageClick?.invoke(message, 0) },
onImageBounds = if (onImageBounds != null) { onImageBounds = if (onImageBounds != null) {
{ rect -> onImageBounds.invoke(imageKey, rect) } { rect -> onImageBounds.invoke(imageKey, rect) }
@@ -467,10 +471,36 @@ fun MessageItem(
} }
) )
} }
if (showPrimaryFileSlot) {
val awaitingServer = message.id < 0 && message.files.isNullOrEmpty()
val isOutboundPendingFile = awaitingServer && pendingHasOutboundFile
val awaitingServerAck = isOutboundPendingFile &&
message.uploadProgress == null
AttachmentPreview(
file = primaryFile,
dmEnvelope = message.dmEnvelope,
currentUserId = currentUserId,
pendingFileUri = message.pendingFileUri,
pendingFilename = message.pendingFilename,
isUploading = isOutboundPendingFile,
awaitingServerAck = awaitingServerAck,
uploadProgress = message.uploadProgress,
fileSizeBytes = message.fileSizes?.firstOrNull(),
messageId = message.id,
fileIndex = 0,
clientMessageId = message.client_message_id,
isAuthor = isAuthor,
messageLabel = message.content,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
message.files?.forEachIndexed { index, file -> message.files?.forEachIndexed { index, file ->
if (index == 0 && showPrimaryImageSlot && isImageFilename(file.name)) { if (index == 0 && showPrimaryImageSlot && isImageFilename(file.name)) {
return@forEachIndexed return@forEachIndexed
} }
if (index == 0 && showPrimaryFileSlot && !isImageFilename(file.name)) {
return@forEachIndexed
}
val isImage = isImageFilename(file.name) val isImage = isImageFilename(file.name)
val imageKey = if (isImage) imageAttachmentKey(message, index) else null val imageKey = if (isImage) imageAttachmentKey(message, index) else null
val isFirstImage = index == 0 && isImage val isFirstImage = index == 0 && isImage
@@ -492,10 +522,9 @@ fun MessageItem(
DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri), DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri),
), ),
fileSizeBytes = message.fileSizes?.getOrNull(index), fileSizeBytes = message.fileSizes?.getOrNull(index),
messageId = if (isImage) message.id else null, messageId = message.id,
fileIndex = if (isImage) index else null, fileIndex = index,
clientMessageId = message.client_message_id, clientMessageId = message.client_message_id,
onFileClick = null,
onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null, onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null,
onImageBounds = if (isImage && imageKey != null && onImageBounds != null) { onImageBounds = if (isImage && imageKey != null && onImageBounds != null) {
{ rect -> onImageBounds.invoke(imageKey, rect) } { rect -> onImageBounds.invoke(imageKey, rect) }
@@ -515,7 +544,6 @@ fun MessageItem(
} }
} }
val hideFilenamePlaceholderCaption = message.pendingFileUri != null && val hideFilenamePlaceholderCaption = message.pendingFileUri != null &&
pendingIsImage &&
message.files.isNullOrEmpty() && message.files.isNullOrEmpty() &&
message.pendingFilename != null && message.pendingFilename != null &&
message.content == message.pendingFilename message.content == message.pendingFilename
@@ -50,6 +50,7 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
else -> panel.pendingFileUri ?: db.pendingFileUri else -> panel.pendingFileUri ?: db.pendingFileUri
}, },
pendingFilename = if (confirmed) null else panel.pendingFilename ?: db.pendingFilename, pendingFilename = if (confirmed) null else panel.pendingFilename ?: db.pendingFilename,
uploadJobId = if (confirmed) null else panel.uploadJobId ?: db.uploadJobId,
pendingFileAspectRatio = if (confirmed) { pendingFileAspectRatio = if (confirmed) {
db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) } db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
?: db.fileAspectRatios?.firstOrNull() ?: db.fileAspectRatios?.firstOrNull()
@@ -57,7 +58,6 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
} else { } else {
panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio
}, },
uploadJobId = if (confirmed) null else panel.uploadJobId ?: db.uploadJobId,
uploadProgress = if (confirmed) null else panel.uploadProgress ?: db.uploadProgress, uploadProgress = if (confirmed) null else panel.uploadProgress ?: db.uploadProgress,
files = db.files ?: panel.files, files = db.files ?: panel.files,
dmEnvelope = db.dmEnvelope ?: panel.dmEnvelope, dmEnvelope = db.dmEnvelope ?: panel.dmEnvelope,
@@ -0,0 +1,21 @@
package ru.fromchat.ui.chat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.core.cache.CacheContext
import ru.fromchat.core.cache.stageOutboundFileForUpload
/**
* Copy a non-image attachment into instance upload storage (same pipeline as images).
*/
suspend fun prepareOutboundFileForSend(
clientMessageId: String,
sourceUri: String,
): StagedOutboundPreview? = withContext(Dispatchers.Default) {
val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: return@withContext null
val staged = runCatching {
stageOutboundFileForUpload(instanceId, clientMessageId, sourceUri)
}.getOrNull() ?: return@withContext null
if (staged.sizeBytes <= 0L) return@withContext null
StagedOutboundPreview(stagedUri = staged.uri, aspectRatio = null)
}
@@ -38,6 +38,7 @@ import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.ui.chat.AvatarInfo import ru.fromchat.ui.chat.AvatarInfo
import ru.fromchat.ui.chat.ChatPanel import ru.fromchat.ui.chat.ChatPanel
import ru.fromchat.ui.chat.DecryptedImageCache import ru.fromchat.ui.chat.DecryptedImageCache
import ru.fromchat.ui.chat.DownloadedFileRegistry
import ru.fromchat.ui.chat.DmTypingHandler import ru.fromchat.ui.chat.DmTypingHandler
import ru.fromchat.ui.chat.TypingHandler import ru.fromchat.ui.chat.TypingHandler
import ru.fromchat.ui.chat.dedupeMessagesByClientId import ru.fromchat.ui.chat.dedupeMessagesByClientId
@@ -290,11 +291,6 @@ class DmPanel(
} }
if (hasOptimistic) { if (hasOptimistic) {
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
if (envelope.replyToId != null) {
val replyTo = _state.messages.find { it.id == envelope.replyToId }
updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
}
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
return@withLock return@withLock
} }
} }
@@ -305,16 +301,17 @@ class DmPanel(
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
} else { } else {
addMessage(createMessage(envelope, outcome.plaintext, outcome.isCorrupted)) addMessage(createMessage(envelope, outcome.plaintext, outcome.isCorrupted))
}
if (envelope.replyToId != null) { if (envelope.replyToId != null) {
val replyTo = _state.messages.find { it.id == envelope.replyToId } val replyTo = _state.messages.find { it.id == envelope.replyToId }
updateMessage(envelope.id) { it.copy(reply_to = replyTo) } updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
} }
scope.launch(Dispatchers.Default) {
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages) MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
} }
} }
} }
}
}
private fun mergeConfirmedOwnMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean) { private fun mergeConfirmedOwnMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean) {
val confirmed = createMessage(envelope, plaintext, isContentCorrupted) val confirmed = createMessage(envelope, plaintext, isContentCorrupted)
@@ -400,7 +397,12 @@ class DmPanel(
} }
else -> currentState.messages + merged else -> currentState.messages + merged
} }
currentState.copy(messages = dedupeMessagesByClientId(newMessages)) val deduped = dedupeMessagesByClientId(newMessages)
currentState.copy(messages = deduped)
}
if (envelope.replyToId != null) {
val replyTo = _state.messages.find { it.id == envelope.replyToId }
updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
} }
} }
@@ -408,6 +410,8 @@ class DmPanel(
MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence) MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence)
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid) OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid)
} }
val snapshot = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, snapshot)
} }
} }
@@ -420,8 +424,10 @@ class DmPanel(
val previous = _state.messages.find { it.id == envelope.id } val previous = _state.messages.find { it.id == envelope.id }
val filesChanged = previous?.files != envelope.files val filesChanged = previous?.files != envelope.files
if (filesChanged) { if (filesChanged) {
DownloadedFileRegistry.invalidateForMessage(envelope.id)
DecryptedImageCache.invalidateForMessage(envelope.id) DecryptedImageCache.invalidateForMessage(envelope.id)
envelope.clientMessageId?.trim()?.takeIf { it.isNotEmpty() }?.let { envelope.clientMessageId?.trim()?.takeIf { it.isNotEmpty() }?.let {
DownloadedFileRegistry.invalidateForClientMessage(it)
DecryptedImageCache.invalidateForClientMessage(it) DecryptedImageCache.invalidateForClientMessage(it)
} }
} }
@@ -492,8 +498,10 @@ class DmPanel(
} }
val clientId = _state.messages.find { it.id == messageId }?.client_message_id val clientId = _state.messages.find { it.id == messageId }?.client_message_id
deleteMessageImmediately(messageId) deleteMessageImmediately(messageId)
DownloadedFileRegistry.invalidateForMessage(messageId)
DecryptedImageCache.invalidateForMessage(messageId) DecryptedImageCache.invalidateForMessage(messageId)
clientId?.trim()?.takeIf { it.isNotEmpty() }?.let { clientId?.trim()?.takeIf { it.isNotEmpty() }?.let {
DownloadedFileRegistry.invalidateForClientMessage(it)
DecryptedImageCache.invalidateForClientMessage(it) DecryptedImageCache.invalidateForClientMessage(it)
} }
runCatching { ApiClient.deleteDm(messageId, otherUserId) } runCatching { ApiClient.deleteDm(messageId, otherUserId) }
@@ -513,8 +521,10 @@ class DmPanel(
if (!involvesPeer) return if (!involvesPeer) return
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val clientId = _state.messages.find { it.id == data.id }?.client_message_id val clientId = _state.messages.find { it.id == data.id }?.client_message_id
DownloadedFileRegistry.invalidateForMessage(data.id)
DecryptedImageCache.invalidateForMessage(data.id) DecryptedImageCache.invalidateForMessage(data.id)
clientId?.trim()?.takeIf { it.isNotEmpty() }?.let { clientId?.trim()?.takeIf { it.isNotEmpty() }?.let {
DownloadedFileRegistry.invalidateForClientMessage(it)
DecryptedImageCache.invalidateForClientMessage(it) DecryptedImageCache.invalidateForClientMessage(it)
} }
deleteMessageImmediately(data.id) deleteMessageImmediately(data.id)
@@ -0,0 +1,118 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSURL
import platform.Foundation.NSUserDomainMask
import platform.Foundation.create
import platform.Foundation.writeToFile
import platform.UIKit.UIDocumentPickerDelegateProtocol
import platform.UIKit.UIDocumentPickerViewController
import platform.darwin.NSObject
import ru.fromchat.platform.iosTopViewController
@Composable
actual fun rememberCreateDownloadDestinationLauncher(
onDestination: (String?) -> Unit,
): (filename: String, mimeType: String) -> Unit {
var activeDelegate by remember { mutableStateOf<SaveDestinationPickerDelegate?>(null) }
val launcher: (String, String) -> Unit = launcher@{ filename, _ ->
val host = iosTopViewController()
if (host == null) {
onDestination(null)
return@launcher
}
val stagingPath = writePickerStagingPlaceholder(filename)
if (stagingPath == null) {
onDestination(null)
return@launcher
}
val fileUrl = NSURL.fileURLWithPath(stagingPath)
val picker = UIDocumentPickerViewController(
forExportingURLs = listOf(fileUrl),
asCopy = true,
)
defaultDownloadsDirectoryUrl()?.let { picker.directoryURL = it }
val delegate = SaveDestinationPickerDelegate(stagingPath) { uri ->
activeDelegate = null
onDestination(uri)
}
activeDelegate = delegate
picker.delegate = delegate
host.presentViewController(picker, animated = true, completion = null)
}
return remember(onDestination) { launcher }
}
private fun defaultDownloadsDirectoryUrl(): NSURL? {
val manager = NSFileManager.defaultManager
return manager.URLForDirectory(
platform.Foundation.NSDownloadsDirectory,
NSUserDomainMask,
null,
false,
null,
) ?: manager.URLForDirectory(
platform.Foundation.NSDocumentDirectory,
NSUserDomainMask,
null,
false,
null,
)
}
@OptIn(ExperimentalForeignApi::class)
private fun writePickerStagingPlaceholder(filename: String): String? {
val caches = NSSearchPathForDirectoriesInDomains(
platform.Foundation.NSCachesDirectory,
NSUserDomainMask,
true,
).filterIsInstance<String>().firstOrNull().orEmpty()
val dir = "$caches/save_export_pick"
NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null)
val path = "$dir/${sanitizeExportFilename(filename)}"
val placeholder = ByteArray(0)
val nsData = placeholder.usePinned { pinned ->
NSData.create(bytes = pinned.addressOf(0), length = 0u)
} ?: return null
return if (nsData.writeToFile(path, true)) path else null
}
private class SaveDestinationPickerDelegate(
private val stagingPath: String,
private val onFinished: (String?) -> Unit,
) : NSObject(), UIDocumentPickerDelegateProtocol {
private var finished = false
private fun finish(uri: String?) {
if (finished) return
finished = true
NSFileManager.defaultManager.removeItemAtPath(stagingPath, null)
onFinished(uri)
}
override fun documentPicker(
controller: UIDocumentPickerViewController,
didPickDocumentsAtURLs: List<*>,
) {
val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL
finish(url?.absoluteString)
}
override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
finish(null)
}
}
@@ -0,0 +1,46 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package ru.fromchat.ui.chat
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSURL
import platform.Foundation.create
import platform.Foundation.writeToFile
import platform.Foundation.writeToURL
import platform.UIKit.UIApplication
import ru.fromchat.platform.iosTopViewController
actual suspend fun writeBytesToExportUri(exportUri: String, bytes: ByteArray): Boolean =
withContext(Dispatchers.Default) {
val url = NSURL.URLWithString(exportUri) ?: NSURL.fileURLWithPath(exportUri.removePrefix("file://"))
val nsData = bytes.usePinned { pinned ->
NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
} ?: return@withContext false
nsData.writeToURL(url, true) || run {
val path = url.path ?: return@withContext false
nsData.writeToFile(path, true)
}
}
actual suspend fun isExportUriAccessible(exportUri: String): Boolean = withContext(Dispatchers.Default) {
val url = NSURL.URLWithString(exportUri) ?: NSURL.fileURLWithPath(exportUri.removePrefix("file://"))
val path = url.path
if (path != null) {
return@withContext NSFileManager.defaultManager.fileExistsAtPath(path)
}
runCatching {
NSFileManager.defaultManager.isReadableFileAtPath(url.absoluteString ?: return@runCatching false)
}.getOrDefault(false)
}
actual fun openExportUri(exportUri: String, mimeType: String): Boolean {
val url = NSURL.URLWithString(exportUri) ?: NSURL.fileURLWithPath(exportUri.removePrefix("file://"))
val host = iosTopViewController() ?: return false
return UIApplication.sharedApplication.openURL(url)
}