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