Make everything more robust

This commit is contained in:
2026-05-25 09:44:40 +03:00
Unverified
parent d9b2545c68
commit 76645e2633
22 changed files with 1035 additions and 69 deletions
@@ -12,6 +12,7 @@ import com.pr0gramm3r101.utils.UtilsLibrary
import org.jetbrains.compose.resources.getString
import 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)
@@ -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)
}