mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Make everything more robust
This commit is contained in:
+12
-3
@@ -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)
|
||||
|
||||
+77
@@ -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_copy">Копировать</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="cd_close">Закрыть</string>
|
||||
<string name="cd_remove">Убрать</string>
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
<string name="action_copy">Copy</string>
|
||||
<string name="action_cancel_send">Cancel</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 -->
|
||||
<string name="cd_close">Close</string>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Pair<Int, Int>>? = 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<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) {
|
||||
return runCatching {
|
||||
val payload = json.decodeFromString<PersistedDmMessagePayload>(trimmed)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.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) {
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user