diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/MediaUploadForegroundHelper.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/MediaUploadForegroundHelper.kt index 4e710be..dc2ea6d 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/MediaUploadForegroundHelper.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/MediaUploadForegroundHelper.kt @@ -12,6 +12,7 @@ import com.pr0gramm3r101.utils.UtilsLibrary import org.jetbrains.compose.resources.getString import ru.fromchat.Res 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_text import ru.fromchat.notif_media_upload_title @@ -30,9 +31,17 @@ object MediaUploadForegroundHelper { val defaultText = getString(Res.string.notif_media_upload_text) ensureChannel(context, channelName) val contentText = when { - percent != null && !filename.isNullOrBlank() -> - getString(Res.string.notif_media_upload_progress, percent.coerceIn(0, 100), filename) - percent != null -> "$percent%" + percent != null -> { + val percentLabel = getString( + 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 } val builder = NotificationCompat.Builder(context, CHANNEL_ID) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.android.kt new file mode 100644 index 0000000..2bc03e8 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.android.kt @@ -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, Uri?>() { + override fun createIntent(context: Context, input: Pair): 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) + } + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.android.kt new file mode 100644 index 0000000..ab823e0 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.android.kt @@ -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) +} diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index df8965d..0780724 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -82,7 +82,8 @@ Удалить Копировать Отменить - %1$d%% · %2$s + %1$d\u0025 + %1$s · %2$s Сохранить Закрыть Убрать diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index f58605a..7d6239b 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -98,7 +98,8 @@ Copy Cancel Save - %1$d%% · %2$s + %1$d\u0025 + %1$s · %2$s Close diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt index 6585703..726e053 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import ru.fromchat.ui.chat.AttachmentMediaLog import ru.fromchat.ui.chat.DecryptedImageCache +import ru.fromchat.ui.chat.DownloadedFileRegistry sealed class AttachmentDownloadProgress { data class InProgress(val storageKey: String, val percent: Int) : AttachmentDownloadProgress() @@ -44,6 +45,7 @@ object AttachmentDownloadNotifier { messageId: Int = 0, fileIndex: Int = 0, clientMessageId: String? = null, + mirrorAsFileAttachment: Boolean = false, ) { val msg = AttachmentMediaLog.messageLabel(messageLabel) val primaryKey = when (progress) { @@ -51,11 +53,12 @@ object AttachmentDownloadNotifier { is AttachmentDownloadProgress.Success -> progress.storageKey is AttachmentDownloadProgress.Failed -> progress.storageKey } - val mirrorKeys = DecryptedImageCache.progressLookupKeys( - messageId = messageId, - fileIndex = fileIndex, - clientMessageId = clientMessageId, - ).ifEmpty { listOf(primaryKey) } + val mirrorKeys = when { + mirrorAsFileAttachment || primaryKey.startsWith("file_") -> + DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId) + else -> + DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId) + }.ifEmpty { listOf(primaryKey) } when (progress) { is AttachmentDownloadProgress.InProgress -> { 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) { - val keys = DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId).toSet() + fun clearProgress( + 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 } _failedKeys.update { failed -> failed - keys } } - fun isFailed(messageId: Int, fileIndex: Int, clientMessageId: String? = null): Boolean = - DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId) - .any { it in _failedKeys.value } + 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) + } + return keys.any { it in _failedKeys.value } + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt index 736e2e5..542054f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt @@ -17,6 +17,14 @@ import ru.fromchat.ui.chat.DecryptedImageCache 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 private data class PersistedDmMessagePayload( @SerialName("text") val text: String, @@ -39,8 +47,25 @@ data class ParsedDmMessageContent( val fileDimensions: List>? = null, val isContentCorrupted: Boolean = false, 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? { message.pendingFileUri?.takeIf { uri -> DecryptedImageCache.isDecryptedImageCacheUri(uri) && localPreviewFileExists(uri) @@ -106,9 +131,21 @@ fun encodePersistedDmMessage(message: Message): String { fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent { val trimmed = plaintext.trim() if (trimmed.startsWith("{")) { - val isPersistedEnvelope = runCatching { - json.parseToJsonElement(trimmed).jsonObject.containsKey("envelope") - }.getOrDefault(false) + val root = runCatching { json.parseToJsonElement(trimmed).jsonObject }.getOrNull() + if (root?.containsKey("pendingFileUri") == true) { + return runCatching { + val payload = json.decodeFromString(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) { return runCatching { val payload = json.decodeFromString(trimmed) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt index 684ac0c..ab2c774 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt @@ -327,7 +327,7 @@ object MessageCacheStore { id = msg.id.toLong(), conversationId = conversationId, userId = msg.user_id.toLong(), - content = msg.content, + content = storedMessageContent(msg), timestamp = msg.timestamp, isRead = if (msg.is_read) 1L else 0L, isEdited = if (msg.is_edited) 1L else 0L, @@ -533,7 +533,11 @@ object MessageCacheStore { isContentCorrupted = parsed.isContentCorrupted, ) 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() ?: parsed.fileDimensions?.firstOrNull()?.let { (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() { withContext(Dispatchers.Default) { db.messageDatabaseQueries.purgeAllCache() @@ -572,12 +582,7 @@ object MessageCacheStore { id = msg.id.toLong(), conversationId = conversationId, userId = msg.user_id.toLong(), - content = when { - msg.id < 0 -> msg.content - !msg.files.isNullOrEmpty() && msg.dmEnvelope != null -> - encodePersistedDmMessage(msg) - else -> msg.content - }, + content = storedMessageContent(msg), timestamp = msg.timestamp, isRead = if (msg.is_read) 1L else 0L, isEdited = if (msg.is_edited) 1L else 0L, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.kt new file mode 100644 index 0000000..23edbac --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMime.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMime.kt new file mode 100644 index 0000000..1557382 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMime.kt @@ -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" + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index ccd2994..e224e1d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AttachFile import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.InsertDriveFile import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -113,7 +114,6 @@ fun AttachmentPreview( messageId: Int? = null, fileIndex: Int? = null, clientMessageId: String? = null, - onFileClick: (() -> Unit)? = null, onImageClick: (() -> Unit)? = null, onImageBounds: ((Rect) -> Unit)? = null, isExpanded: Boolean = false, @@ -139,7 +139,7 @@ fun AttachmentPreview( when { (file != null && !isImage) || isPendingFile -> { - ExpressiveFileAttachmentRow( + ChatFileAttachmentTile( filename = file?.name ?: pendingFilename?.takeIf { it.isNotBlank() } ?: pendingFileUri?.substringAfterLast("/") @@ -147,11 +147,18 @@ fun AttachmentPreview( ?.takeIf { it.isNotBlank() } ?: "File", 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, isUploading = isPendingFile && (isUploading || awaitingServerAck), uploadProgress = if (isPendingFile) uploadProgress else null, - modifier = modifier + messageLabel = messageLabel, + modifier = modifier, ) } showImageTile -> { @@ -962,13 +969,14 @@ private fun formatFileSize(bytes: Long): String { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ExpressiveFileAttachmentRow( +internal fun ExpressiveFileAttachmentRow( filename: String, sizeBytes: Long?, onClick: (() -> Unit)?, isAuthor: Boolean, isUploading: Boolean, uploadProgress: Int?, + isDownloaded: Boolean = false, modifier: Modifier = Modifier ) { val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface @@ -1022,7 +1030,11 @@ private fun ExpressiveFileAttachmentRow( contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Rounded.Download, + imageVector = if (isDownloaded) { + Icons.Rounded.InsertDriveFile + } else { + Icons.Rounded.Download + }, contentDescription = null, modifier = Modifier.size(26.dp), tint = if (isAuthor) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatFileAttachmentTile.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatFileAttachmentTile.kt new file mode 100644 index 0000000..19a302a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatFileAttachmentTile.kt @@ -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(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(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?, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index cc5b075..ae32c90 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -403,12 +403,12 @@ fun ChatScreen( contextMenuState = contextMenuState.copy(isOpen = false, message = null) return@LaunchedEffect } - val menuAuthor = menuMessage.user_id == currentUserId val liveAuthor = liveMessage.user_id == currentUserId - val menuFp = messageContextMenuFingerprint(menuMessage, menuAuthor, isReadOnly) val liveFp = messageContextMenuFingerprint(liveMessage, liveAuthor, isReadOnly) - if (menuFp != liveFp) { - contextMenuState = contextMenuState.copy(isOpen = false, message = null) + val menuAuthor = menuMessage.user_id == currentUserId + 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, ) } else { - null + prepareOutboundFileForSend( + clientMessageId = jobId, + sourceUri = att.uri, + ) } val fileUri = staged?.stagedUri ?: att.uri val optimisticMessage = Message( @@ -522,14 +525,14 @@ fun ChatScreen( pendingFileUri = fileUri, pendingFilename = att.filename, uploadJobId = jobId, - uploadProgress = if (isImage) 0 else null, + uploadProgress = 0, pendingFileAspectRatio = staged?.aspectRatio ?: aspectRatio, fileDimensions = imageDimensions?.let { listOf(it) }, ) withContext(Dispatchers.Main) { panel.addMessage(optimisticMessage) } - if (isImage && staged == null) { + if (staged == null) { withContext(Dispatchers.Main) { panel.cancelQueuedMessageByClientId(jobId) } @@ -626,7 +629,15 @@ fun ChatScreen( message = message, isAuthor = message.user_id == currentUserId, 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 = { if (isReadOnly) { return@MessageItem diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.kt new file mode 100644 index 0000000..aedf6f5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DownloadedFileRegistry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DownloadedFileRegistry.kt new file mode 100644 index 0000000..7e5a7ce --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DownloadedFileRegistry.kt @@ -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() + 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 = 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 = 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 { + 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>(bytes.decodeToString()) + }.getOrDefault(emptyMap()) + } + + private fun saveIndexLocked(index: Map) { + val path = indexPath() ?: return + val bytes = json.encodeToString(index).encodeToByteArray() + PlatformFileSystem.writeBytes(path, bytes) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.kt index 340a191..1809b7a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageImageSave.kt @@ -16,17 +16,7 @@ data class SavableMessageImage( val mimeType: String, ) -fun mimeTypeForImageFilename(filename: String): String { - 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" - } -} +fun mimeTypeForImageFilename(filename: String): String = mimeTypeForFilename(filename) /** Local decrypted (or staged) image ready to copy to user storage. */ fun isMessageImageFullyLoaded(message: Message, fileIndex: Int): Boolean { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 67eb387..d264db9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -202,6 +202,9 @@ fun MessageItem( ) else -> false } + val pendingHasOutboundFile = message.pendingFileUri != null && + message.files.isNullOrEmpty() && + !pendingIsImage val firstContentIsImage = ( !showUsername || isAuthor ) && message.reply_to == null && ( @@ -418,6 +421,8 @@ fun MessageItem( val primaryFile = message.files?.firstOrNull() val primaryIsImage = primaryFile != null && isImageFilename(primaryFile.name) val showPrimaryImageSlot = pendingIsImage || primaryIsImage + val showPrimaryFileSlot = pendingHasOutboundFile || + (primaryFile != null && !primaryIsImage) if (showPrimaryImageSlot) { val imageKey = imageAttachmentKey(message, 0) val awaitingServer = message.id < 0 && message.files.isNullOrEmpty() @@ -448,7 +453,6 @@ fun MessageItem( messageId = message.id, fileIndex = 0, clientMessageId = message.client_message_id, - onFileClick = null, onImageClick = { onImageClick?.invoke(message, 0) }, onImageBounds = if (onImageBounds != null) { { 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 -> if (index == 0 && showPrimaryImageSlot && isImageFilename(file.name)) { return@forEachIndexed } + if (index == 0 && showPrimaryFileSlot && !isImageFilename(file.name)) { + return@forEachIndexed + } val isImage = isImageFilename(file.name) val imageKey = if (isImage) imageAttachmentKey(message, index) else null val isFirstImage = index == 0 && isImage @@ -492,10 +522,9 @@ fun MessageItem( DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri), ), fileSizeBytes = message.fileSizes?.getOrNull(index), - messageId = if (isImage) message.id else null, - fileIndex = if (isImage) index else null, + messageId = message.id, + fileIndex = index, clientMessageId = message.client_message_id, - onFileClick = null, onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null, onImageBounds = if (isImage && imageKey != null && onImageBounds != null) { { rect -> onImageBounds.invoke(imageKey, rect) } @@ -515,7 +544,6 @@ fun MessageItem( } } val hideFilenamePlaceholderCaption = message.pendingFileUri != null && - pendingIsImage && message.files.isNullOrEmpty() && message.pendingFilename != null && message.content == message.pendingFilename diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt index 763a72b..d4280b6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt @@ -50,6 +50,7 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message { else -> panel.pendingFileUri ?: db.pendingFileUri }, pendingFilename = if (confirmed) null else panel.pendingFilename ?: db.pendingFilename, + uploadJobId = if (confirmed) null else panel.uploadJobId ?: db.uploadJobId, pendingFileAspectRatio = if (confirmed) { db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) } ?: db.fileAspectRatios?.firstOrNull() @@ -57,7 +58,6 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message { } else { panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio }, - uploadJobId = if (confirmed) null else panel.uploadJobId ?: db.uploadJobId, uploadProgress = if (confirmed) null else panel.uploadProgress ?: db.uploadProgress, files = db.files ?: panel.files, dmEnvelope = db.dmEnvelope ?: panel.dmEnvelope, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileStaging.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileStaging.kt new file mode 100644 index 0000000..80e29ec --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileStaging.kt @@ -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) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index 0451ecb..e7ef448 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -38,6 +38,7 @@ import ru.fromchat.crypto.decryptEnvelope import ru.fromchat.ui.chat.AvatarInfo import ru.fromchat.ui.chat.ChatPanel import ru.fromchat.ui.chat.DecryptedImageCache +import ru.fromchat.ui.chat.DownloadedFileRegistry import ru.fromchat.ui.chat.DmTypingHandler import ru.fromchat.ui.chat.TypingHandler import ru.fromchat.ui.chat.dedupeMessagesByClientId @@ -290,11 +291,6 @@ class DmPanel( } if (hasOptimistic) { 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 } } @@ -305,13 +301,14 @@ class DmPanel( mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) } else { addMessage(createMessage(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) } + } + scope.launch(Dispatchers.Default) { + MessageCacheStore.replaceDmMessages(otherUserId, _state.messages) + } } - 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) } } } @@ -400,7 +397,12 @@ class DmPanel( } 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) 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 filesChanged = previous?.files != envelope.files if (filesChanged) { + DownloadedFileRegistry.invalidateForMessage(envelope.id) DecryptedImageCache.invalidateForMessage(envelope.id) envelope.clientMessageId?.trim()?.takeIf { it.isNotEmpty() }?.let { + DownloadedFileRegistry.invalidateForClientMessage(it) DecryptedImageCache.invalidateForClientMessage(it) } } @@ -492,8 +498,10 @@ class DmPanel( } val clientId = _state.messages.find { it.id == messageId }?.client_message_id deleteMessageImmediately(messageId) + DownloadedFileRegistry.invalidateForMessage(messageId) DecryptedImageCache.invalidateForMessage(messageId) clientId?.trim()?.takeIf { it.isNotEmpty() }?.let { + DownloadedFileRegistry.invalidateForClientMessage(it) DecryptedImageCache.invalidateForClientMessage(it) } runCatching { ApiClient.deleteDm(messageId, otherUserId) } @@ -513,8 +521,10 @@ class DmPanel( if (!involvesPeer) return scope.launch(Dispatchers.Default) { val clientId = _state.messages.find { it.id == data.id }?.client_message_id + DownloadedFileRegistry.invalidateForMessage(data.id) DecryptedImageCache.invalidateForMessage(data.id) clientId?.trim()?.takeIf { it.isNotEmpty() }?.let { + DownloadedFileRegistry.invalidateForClientMessage(it) DecryptedImageCache.invalidateForClientMessage(it) } deleteMessageImmediately(data.id) diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.ios.kt new file mode 100644 index 0000000..bef17f5 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.ios.kt @@ -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(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().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) + } +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.ios.kt new file mode 100644 index 0000000..f430b35 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.ios.kt @@ -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) +}