diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml
index a9fd61c..5bbb63b 100644
--- a/app/android/src/main/AndroidManifest.xml
+++ b/app/android/src/main/AndroidManifest.xml
@@ -4,6 +4,8 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/android/src/main/kotlin/ru/fromchat/App.kt b/app/android/src/main/kotlin/ru/fromchat/App.kt
index 25ce532..c8913d2 100644
--- a/app/android/src/main/kotlin/ru/fromchat/App.kt
+++ b/app/android/src/main/kotlin/ru/fromchat/App.kt
@@ -1,7 +1,6 @@
package ru.fromchat
import android.app.Application
-import android.util.Log
import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -11,9 +10,8 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient
+import ru.fromchat.api.AttachmentTransferBootstrap
import ru.fromchat.api.WebSocketManager
-import ru.fromchat.fcm.ensureFcmTokenRegistered
-import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
import ru.fromchat.notifications.NotificationHelper
class App: Application() {
@@ -92,21 +90,8 @@ class App: Application() {
}
GlobalScope.launch(Dispatchers.IO) {
- runCatching {
- ApiClient.loadPersistedData()
- }
-
- runCatching {
- uploadPendingFcmTokenIfAvailable()
- }
-
- // If we have an auth token, try to get current FCM token and register it immediately
- runCatching {
- val isRegistered = ensureFcmTokenRegistered()
- if (!isRegistered) {
- Log.d("AppFCM", "FCM token registration skipped or deferred")
- }
- }
+ runCatching { ApiClient.loadPersistedData() }
+ AttachmentTransferBootstrap.launchOnApplicationStart()
}
}
}
\ No newline at end of file
diff --git a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt
index 7543aa7..b10e0d2 100644
--- a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt
+++ b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt
@@ -22,7 +22,6 @@ import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
-import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -41,9 +40,6 @@ private const val EXTRA_MESSAGE_ID = "scroll_to_message_id"
private const val CHAT_TYPE_PUBLIC = "public"
private const val CHAT_TYPE_DM = "dm"
private const val INVALID_PROFILE_DEEP_LINK_MESSAGE = "Could not open this profile link. Use fromchat://u/."
-private const val PROFILE_NOT_FOUND_MESSAGE = "This profile could not be found"
-private const val PROFILE_OPEN_FAILED_MESSAGE = "Could not open this profile. Please try again."
-
private data class ProfileDeepLinkResolution(
val scrollToMessageId: Int? = null,
val startAtPublicChat: Boolean = false,
@@ -53,13 +49,6 @@ private data class ProfileDeepLinkResolution(
val profileLookupErrorMessage: String? = null
)
-private fun getProfileLookupFailureMessage(error: Throwable): String =
- if (error is ClientRequestException && error.response.status.value == 404) {
- PROFILE_NOT_FOUND_MESSAGE
- } else {
- PROFILE_OPEN_FAILED_MESSAGE
- }
-
private data class ProfileDeepLinkTarget(
val userId: Int? = null,
val username: String? = null,
@@ -78,7 +67,7 @@ class MainActivity : ComponentActivity() {
ActivityResultContracts.RequestPermission()
) {}
- private suspend fun buildLaunchState(intent: Intent?): ProfileDeepLinkResolution {
+ private fun parseLaunchStateFromIntent(intent: Intent?): ProfileDeepLinkResolution {
Logger.d(
"ProfileDeepLink",
"handleIntent: action=${intent?.action}, data=${intent?.dataString}, messageId=${intent?.getIntExtra(EXTRA_MESSAGE_ID, -1)}, " +
@@ -93,6 +82,10 @@ class MainActivity : ComponentActivity() {
"handleIntent parsedProfileTarget: userId=${profileTarget?.userId}, username=${profileTarget?.username}, parseError=${profileTarget?.parseError}"
)
+ if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
+ markMessagesAsRead()
+ }
+
val baseState = ProfileDeepLinkResolution(
scrollToMessageId = if (messageId != -1) messageId else null,
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM,
@@ -111,39 +104,26 @@ class MainActivity : ComponentActivity() {
return baseState
}
- var lookupFailureMessage: String? = null
- val resolvedProfileId = if (profileTarget.userId != null) {
- runCatching {
- ApiClient.getProfileById(profileTarget.userId).id
- }.onFailure { err ->
- Logger.d("ProfileDeepLink", "profile deep link lookup failed by id: ${err.message}")
- lookupFailureMessage = getProfileLookupFailureMessage(err)
- }.getOrNull()
- } else if (!profileTarget.username.isNullOrBlank()) {
- runCatching {
- ApiClient.getProfileByUsername(profileTarget.username).id
- }.onFailure { err ->
- Logger.d("ProfileDeepLink", "profile deep link lookup failed by username: ${err.message}")
- lookupFailureMessage = getProfileLookupFailureMessage(err)
- }.getOrNull()
- } else {
- null
+ val profileUserId = profileTarget.userId
+ val profileUsername = profileTarget.username?.trim().orEmpty()
+ if (profileUserId != null && profileUserId > 0) {
+ return baseState.copy(
+ startAtProfileUserId = profileUserId,
+ startAtProfileUsername = null,
+ startAtDmConversationUserId = null,
+ startAtPublicChat = false,
+ )
+ }
+ if (profileUsername.isNotEmpty()) {
+ return baseState.copy(
+ startAtProfileUserId = null,
+ startAtProfileUsername = profileUsername,
+ startAtDmConversationUserId = null,
+ startAtPublicChat = false,
+ )
}
- if (resolvedProfileId == null) {
- return baseState.copy(profileLookupErrorMessage = lookupFailureMessage ?: PROFILE_OPEN_FAILED_MESSAGE)
- }
-
- if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
- markMessagesAsRead()
- }
-
- return baseState.copy(
- startAtProfileUserId = resolvedProfileId,
- startAtProfileUsername = null,
- startAtDmConversationUserId = null,
- startAtPublicChat = false
- )
+ return baseState
}
private fun applyLaunchState(launchState: ProfileDeepLinkResolution) {
@@ -235,23 +215,22 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
enableEdgeToEdge()
- lifecycleScope.launch {
- val launchState = buildLaunchState(intent)
- applyLaunchState(launchState)
- setContent {
- App(
- scrollToMessageId = scrollToMessageId,
- startAtPublicChat = startAtPublicChat,
- startAtDmConversationUserId = startAtDmConversationUserId,
- startAtProfileUserId = startAtProfileUserId,
- startAtProfileUsername = startAtProfileUsername,
- profileLookupErrorMessage = profileLookupErrorMessage,
- onProfileLookupErrorMessageConsumed = {
- profileLookupErrorMessage = null
- }
- )
- }
+ applyLaunchState(parseLaunchStateFromIntent(intent))
+ setContent {
+ App(
+ scrollToMessageId = scrollToMessageId,
+ startAtPublicChat = startAtPublicChat,
+ startAtDmConversationUserId = startAtDmConversationUserId,
+ startAtProfileUserId = startAtProfileUserId,
+ startAtProfileUsername = startAtProfileUsername,
+ profileLookupErrorMessage = profileLookupErrorMessage,
+ onProfileLookupErrorMessageConsumed = {
+ profileLookupErrorMessage = null
+ }
+ )
+ }
+ lifecycleScope.launch {
checkGooglePlayServices()
}
@@ -263,10 +242,8 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
- lifecycleScope.launch {
- val launchState = buildLaunchState(intent)
- applyLaunchState(launchState)
- }
+ setIntent(intent)
+ applyLaunchState(parseLaunchStateFromIntent(intent))
}
override fun onPause() {
diff --git a/app/android/src/main/res/xml/fromchat_attachment_paths.xml b/app/android/src/main/res/xml/fromchat_attachment_paths.xml
new file mode 100644
index 0000000..3acc319
--- /dev/null
+++ b/app/android/src/main/res/xml/fromchat_attachment_paths.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/ios/iosApp/iOSApp.swift b/app/ios/iosApp/iOSApp.swift
index b0d2cf1..4412151 100644
--- a/app/ios/iosApp/iOSApp.swift
+++ b/app/ios/iosApp/iOSApp.swift
@@ -2,6 +2,10 @@ import SwiftUI
@main
struct iOSApp: App {
+ init() {
+ IosApplicationBootstrapKt.launchOnApplicationStart()
+ }
+
var body: some Scene {
WindowGroup {
ContentView()
diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts
index 8644bbf..3eb0683 100644
--- a/app/shared/build.gradle.kts
+++ b/app/shared/build.gradle.kts
@@ -59,6 +59,7 @@ kotlin {
// Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3
implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.websockets)
@@ -83,11 +84,13 @@ kotlin {
}
androidMain.dependencies {
+ implementation(libs.bouncycastle.bcprov)
implementation(libs.androidx.exifinterface)
implementation(libs.ktor.client.okhttp)
implementation(libs.firebase.messaging)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.multiplatform.crypto.libsodium.bindings)
implementation(libs.tweetnacl.java)
implementation(libs.sqldelight.driver.android)
implementation(libs.livekit.android)
diff --git a/app/shared/src/androidMain/AndroidManifest.xml b/app/shared/src/androidMain/AndroidManifest.xml
index 8b60dae..ec26fe8 100644
--- a/app/shared/src/androidMain/AndroidManifest.xml
+++ b/app/shared/src/androidMain/AndroidManifest.xml
@@ -12,6 +12,14 @@
android:name="ru.fromchat.calls.CallForegroundService"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
+
+
{
+ val uri = Uri.parse(fileUri)
+ UtilsLibrary.context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { afd: AssetFileDescriptor ->
+ val len = afd.length
+ if (len >= 0L) len else null
+ }
+ }
+ fileUri.startsWith("file://") -> {
+ val path = Uri.parse(fileUri).path ?: return@withContext null
+ val file = File(path)
+ if (!file.isFile) null else file.length()
+ }
+ else -> {
+ val file = File(fileUri)
+ if (!file.isFile) null else file.length()
+ }
+ }
}
actual suspend fun stageOutboundFileForUpload(
instanceId: String,
clientMessageId: String,
sourceUri: String,
+ expectedSizeBytes: Long,
): StagedOutboundFile = withContext(Dispatchers.IO) {
+ repairInterruptedUploadArtifacts(instanceId, clientMessageId)
val dest = sourceFile(instanceId, clientMessageId)
val destUri = Uri.fromFile(dest).toString()
- if (sourceUri != destUri && sourceUri != dest.absolutePath) {
- if (!dest.isFile || dest.length() == 0L) {
- val input = when {
- sourceUri.startsWith("content://") || sourceUri.startsWith("file://") ->
- UtilsLibrary.context.contentResolver.openInputStream(Uri.parse(sourceUri))
- else -> File(sourceUri).inputStream()
- } ?: throw OutboundFileUnavailableException("Failed to read file from URI")
- input.use { inputStream ->
- dest.outputStream().use { output -> inputStream.copyTo(output) }
- }
+ if (sourceUri == destUri || sourceUri == dest.absolutePath) {
+ if (!isStagedSourceReady(instanceId, clientMessageId, expectedSizeBytes)) {
+ throw OutboundFileUnavailableException("Staged source file is incomplete")
+ }
+ return@withContext StagedOutboundFile(uri = destUri, sizeBytes = dest.length())
+ }
+ if (isStagedSourceReady(instanceId, clientMessageId, expectedSizeBytes)) {
+ return@withContext StagedOutboundFile(uri = destUri, sizeBytes = dest.length())
+ }
+ val part = sourcePartFile(instanceId, clientMessageId)
+ dest.delete()
+ sourceOkFile(instanceId, clientMessageId).delete()
+ part.delete()
+ val input = when {
+ sourceUri.startsWith("content://") || sourceUri.startsWith("file://") ->
+ UtilsLibrary.context.contentResolver.openInputStream(Uri.parse(sourceUri))
+ else -> File(sourceUri).takeIf { it.isFile }?.inputStream()
+ } ?: throw OutboundFileUnavailableException("Failed to read file from URI")
+ input.use { inputStream ->
+ FileOutputStream(part).use { output ->
+ inputStream.copyTo(output)
+ output.flush()
+ output.fd.sync()
}
}
- StagedOutboundFile(uri = destUri, sizeBytes = dest.length().coerceAtLeast(0L))
+ atomicReplace(part, dest)
+ val stagedBytes = dest.length()
+ val expected = expectedSizeBytes.takeIf { it > 0L } ?: stagedBytes
+ if (expectedSizeBytes > 0L && stagedBytes != expectedSizeBytes) {
+ dest.delete()
+ sourceOkFile(instanceId, clientMessageId).delete()
+ throw OutboundFileUnavailableException("Staged file size mismatch")
+ }
+ writeOkMarker(sourceOkFile(instanceId, clientMessageId), stagedBytes, expected)
+ StagedOutboundFile(uri = destUri, sizeBytes = stagedBytes)
}
+actual suspend fun isStagedSourceReady(
+ instanceId: String,
+ clientMessageId: String,
+ expectedSizeBytes: Long,
+): Boolean = withContext(Dispatchers.IO) {
+ readOkMarker(
+ sourceOkFile(instanceId, clientMessageId),
+ sourceFile(instanceId, clientMessageId),
+ expectedSizeBytes,
+ )
+}
+
+actual suspend fun isEncryptedBlobReady(
+ instanceId: String,
+ clientMessageId: String,
+ expectedEncryptedSizeBytes: Long?,
+): Boolean = withContext(Dispatchers.IO) {
+ val expected = expectedEncryptedSizeBytes?.takeIf { it > 0L } ?: 0L
+ val enc = blobFile(instanceId, clientMessageId)
+ val ok = blobOkFile(instanceId, clientMessageId)
+ if (!readOkMarker(ok, enc, expected)) return@withContext false
+ cipherFile(instanceId, clientMessageId).isFile
+}
+
+actual suspend fun commitEncryptedUploadBlob(
+ instanceId: String,
+ clientMessageId: String,
+ encryptedSizeBytes: Long,
+): Unit = withContext(Dispatchers.IO) {
+ val part = blobPartFile(instanceId, clientMessageId)
+ val final = blobFile(instanceId, clientMessageId)
+ if (part.isFile) {
+ atomicReplace(part, final)
+ } else if (!final.isFile) {
+ error("Encrypted upload blob missing")
+ }
+ if (final.length() != encryptedSizeBytes) {
+ throw OutboundFileUnavailableException("Encrypted blob size mismatch after commit")
+ }
+ writeOkMarker(blobOkFile(instanceId, clientMessageId), encryptedSizeBytes, encryptedSizeBytes)
+}
+
+actual suspend fun repairInterruptedUploadArtifacts(
+ instanceId: String,
+ clientMessageId: String,
+): Unit = withContext(Dispatchers.IO) {
+ sourcePartFile(instanceId, clientMessageId).delete()
+ blobPartFile(instanceId, clientMessageId).delete()
+ cipherPartFile(instanceId, clientMessageId).delete()
+ val enc = blobFile(instanceId, clientMessageId)
+ val encOk = blobOkFile(instanceId, clientMessageId)
+ if (!readOkMarker(encOk, enc, 0L)) {
+ enc.delete()
+ encOk.delete()
+ cipherFile(instanceId, clientMessageId).delete()
+ }
+ val source = sourceFile(instanceId, clientMessageId)
+ val sourceOk = sourceOkFile(instanceId, clientMessageId)
+ if (source.isFile && !readOkMarker(sourceOk, source, 0L)) {
+ source.delete()
+ sourceOk.delete()
+ }
+}
+
+private class AndroidOutboundFileInputStream(
+ private val input: java.io.InputStream,
+) : OutboundFileInputStream {
+ override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int =
+ withContext(Dispatchers.IO) {
+ input.read(buffer, offset, length)
+ }
+
+ override suspend fun close() {
+ withContext(Dispatchers.IO) {
+ input.close()
+ }
+ }
+}
+
+actual suspend fun openOutboundFileInputStream(fileUri: String): OutboundFileInputStream? =
+ withContext(Dispatchers.IO) {
+ val stream = when {
+ fileUri.startsWith("content://") || fileUri.startsWith("file://") ->
+ UtilsLibrary.context.contentResolver.openInputStream(Uri.parse(fileUri))
+ else -> {
+ val file = File(fileUri)
+ if (!file.isFile) return@withContext null
+ file.inputStream()
+ }
+ } ?: return@withContext null
+ AndroidOutboundFileInputStream(stream)
+ }
+
actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
withContext(Dispatchers.IO) {
when {
@@ -68,41 +250,122 @@ actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
}
}
+actual suspend fun copyOutboundFileToPath(sourceUri: String, destinationPath: String) {
+ withContext(Dispatchers.IO) {
+ val dest = File(destinationPath)
+ dest.parentFile?.mkdirs()
+ val input = when {
+ sourceUri.startsWith("content://") || sourceUri.startsWith("file://") ->
+ UtilsLibrary.context.contentResolver.openInputStream(Uri.parse(sourceUri))
+ else -> File(sourceUri.removePrefix("file://")).takeIf { it.isFile }?.inputStream()
+ } ?: throw OutboundFileUnavailableException("Failed to read file from URI")
+ input.use { inputStream ->
+ FileOutputStream(dest).use { output ->
+ inputStream.copyTo(output)
+ output.flush()
+ output.fd.sync()
+ }
+ }
+ }
+}
+
actual suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray) {
withContext(Dispatchers.IO) {
- blobFile(instanceId, clientMessageId).outputStream().use { it.write(bytes) }
+ repairInterruptedUploadArtifacts(instanceId, clientMessageId)
+ val part = blobPartFile(instanceId, clientMessageId)
+ part.delete()
+ blobOkFile(instanceId, clientMessageId).delete()
+ FileOutputStream(part).use { it.write(bytes) }
+ part.syncOutput()
}
}
actual suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? =
withContext(Dispatchers.IO) {
+ if (!isEncryptedBlobReady(instanceId, clientMessageId, null)) return@withContext null
val f = blobFile(instanceId, clientMessageId)
if (!f.isFile || f.length() == 0L) null else f.readBytes()
}
-actual suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String) {
+actual suspend fun encryptedUploadBlobSizeBytes(instanceId: String, clientMessageId: String): Long? =
withContext(Dispatchers.IO) {
- cipherFile(instanceId, clientMessageId).writeText(json)
+ if (!isEncryptedBlobReady(instanceId, clientMessageId, null)) return@withContext null
+ val f = blobFile(instanceId, clientMessageId)
+ if (!f.isFile || f.length() <= 0L) null else f.length()
+ }
+
+actual suspend fun readEncryptedUploadBlobRange(
+ instanceId: String,
+ clientMessageId: String,
+ offset: Long,
+ length: Int,
+): ByteArray = withContext(Dispatchers.IO) {
+ if (!isEncryptedBlobReady(instanceId, clientMessageId, null)) {
+ throw OutboundFileUnavailableException("Encrypted upload blob not committed")
+ }
+ val f = blobFile(instanceId, clientMessageId)
+ if (!f.isFile) throw OutboundFileUnavailableException("Encrypted upload blob missing")
+ if (length <= 0) return@withContext ByteArray(0)
+ f.inputStream().use { input ->
+ val skipped = input.skip(offset)
+ if (skipped < offset) throw OutboundFileUnavailableException("Encrypted upload blob truncated")
+ val buffer = ByteArray(length)
+ var read = 0
+ while (read < length) {
+ val n = input.read(buffer, read, length - read)
+ if (n <= 0) break
+ read += n
+ }
+ if (read < length) {
+ throw OutboundFileUnavailableException("Encrypted upload blob truncated")
+ }
+ buffer
+ }
+}
+
+actual suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String) {
+ saveUploadTransportCipherJsonAtomic(instanceId, clientMessageId, json)
+}
+
+actual suspend fun saveUploadTransportCipherJsonAtomic(
+ instanceId: String,
+ clientMessageId: String,
+ json: String,
+) {
+ withContext(Dispatchers.IO) {
+ val part = cipherPartFile(instanceId, clientMessageId)
+ val final = cipherFile(instanceId, clientMessageId)
+ part.writeText(json)
+ part.syncOutput()
+ atomicReplace(part, final)
}
}
actual suspend fun loadUploadTransportCipherJson(instanceId: String, clientMessageId: String): String? =
withContext(Dispatchers.IO) {
- val f = cipherFile(instanceId, clientMessageId)
- if (!f.isFile) null else f.readText().takeIf { it.isNotBlank() }
+ if (!cipherFile(instanceId, clientMessageId).isFile) return@withContext null
+ cipherFile(instanceId, clientMessageId).readText().takeIf { it.isNotBlank() }
}
actual suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String) {
withContext(Dispatchers.IO) {
- blobFile(instanceId, clientMessageId).delete()
- cipherFile(instanceId, clientMessageId).delete()
sourceFile(instanceId, clientMessageId).delete()
+ sourcePartFile(instanceId, clientMessageId).delete()
+ sourceOkFile(instanceId, clientMessageId).delete()
+ blobFile(instanceId, clientMessageId).delete()
+ blobPartFile(instanceId, clientMessageId).delete()
+ blobOkFile(instanceId, clientMessageId).delete()
+ cipherFile(instanceId, clientMessageId).delete()
+ cipherPartFile(instanceId, clientMessageId).delete()
}
}
actual suspend fun clearUploadSecretsOnly(instanceId: String, clientMessageId: String) {
withContext(Dispatchers.IO) {
blobFile(instanceId, clientMessageId).delete()
+ blobPartFile(instanceId, clientMessageId).delete()
+ blobOkFile(instanceId, clientMessageId).delete()
cipherFile(instanceId, clientMessageId).delete()
+ cipherPartFile(instanceId, clientMessageId).delete()
}
}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/core/files/AttachmentFileProvider.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/core/files/AttachmentFileProvider.kt
new file mode 100644
index 0000000..a732460
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/core/files/AttachmentFileProvider.kt
@@ -0,0 +1,132 @@
+package ru.fromchat.core.files
+
+import android.content.Context
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import android.provider.DocumentsContract
+import android.provider.OpenableColumns
+import androidx.core.content.FileProvider
+import java.io.File
+
+/**
+ * Serves decrypted attachment files to other apps (installers, viewers).
+ * Supplies [OpenableColumns.DISPLAY_NAME] — required by SAI and some document providers.
+ */
+class AttachmentFileProvider : FileProvider() {
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?,
+ ): Cursor {
+ val file = resolveFile(uri)
+ // Some installers (notably SAI) will crash if DISPLAY_NAME exists but is null.
+ // Also, some callers query our URI in ways where FileProvider's internal resolution
+ // may work while our custom resolveFile() returns null (e.g. URI forms or encodings).
+ // So we always return a row with a best-effort display name.
+ val columns = projection?.takeIf { it.isNotEmpty() }
+ ?: arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
+ val row = MatrixCursor(columns, 1)
+ val values = arrayOfNulls(columns.size)
+ val safeDisplayName = file?.let { displayNameFor(it) }
+ ?: uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
+ ?: "attachment"
+ val safeSize = file?.length()
+ for (i in columns.indices) {
+ values[i] = when (columns[i]) {
+ // Many installers/document providers don't use OpenableColumns constants directly.
+ // Populate common aliases so DISPLAY_NAME is never null when a name is requested.
+ OpenableColumns.DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ "display_name",
+ "_display_name",
+ "name",
+ "filename",
+ "title" ->
+ safeDisplayName
+ OpenableColumns.SIZE,
+ DocumentsContract.Document.COLUMN_SIZE,
+ "size",
+ "_size" ->
+ safeSize
+ else -> null
+ }
+ }
+ row.addRow(values)
+ return row
+ }
+
+ override fun getType(uri: Uri): String? {
+ val file = resolveFile(uri) ?: return super.getType(uri)
+ val name = displayNameFor(file)
+ return when {
+ name.endsWith(".apk", ignoreCase = true) ||
+ name.endsWith(".apks", ignoreCase = true) ||
+ name.endsWith(".xapk", ignoreCase = true) ||
+ name.endsWith(".apkm", ignoreCase = true) ->
+ "application/vnd.android.package-archive"
+ else -> super.getType(uri)
+ }
+ }
+
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
+ val file = resolveFile(uri)
+ if (file == null) {
+ return super.openFile(uri, mode)
+ ?: error("Failed to open attachment file")
+ }
+ val fileMode = ParcelFileDescriptor.parseMode(mode)
+ return ParcelFileDescriptor.open(file, fileMode)
+ ?: error("Failed to open attachment file")
+ }
+
+ companion object {
+ fun uriForFile(context: Context, file: File): Uri? = runCatching {
+ FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.attachment_files",
+ file,
+ )
+ }.getOrNull()
+
+ /** Strips cache storage-key prefix from on-disk basename (see [ru.fromchat.ui.chat.DecryptedFileCache]). */
+ internal fun displayNameFor(file: File): String =
+ displayNameFromBasename(file.name)
+
+ internal fun displayNameFromBasename(basename: String): String {
+ Regex("^file_(\\d+)_(\\d+)_(.+)$").matchEntire(basename)?.let {
+ return it.groupValues[3]
+ }
+ Regex("^file_c_(.+)_(\\d+)_(.+)$").matchEntire(basename)?.let {
+ return it.groupValues[3]
+ }
+ return basename
+ }
+ }
+
+ private fun resolveFile(uri: Uri): File? {
+ val ctx = context ?: return null
+ if (uri.authority != "${ctx.packageName}.attachment_files") return null
+ val segments = uri.pathSegments
+ if (segments.isEmpty()) return null
+ val root = when (segments.first()) {
+ "decrypted_files" -> File(ctx.cacheDir, "decrypted_files")
+ "decrypted_images" -> File(ctx.cacheDir, "decrypted_images")
+ "fromchat" -> File(ctx.cacheDir, "fromchat")
+ else -> return null
+ }
+ val relative = segments.drop(1).joinToString("/")
+ if (relative.isEmpty()) return null
+ val file = File(root, relative)
+ return file.takeIf { it.isFile }
+ }
+
+ private fun emptyResultCursor(projection: Array?): Cursor {
+ val columns = projection?.takeIf { it.isNotEmpty() }
+ ?: arrayOf(OpenableColumns.DISPLAY_NAME)
+ return MatrixCursor(columns, 0)
+ }
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/core/files/FileWriteSink.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/core/files/FileWriteSink.android.kt
new file mode 100644
index 0000000..6592500
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/core/files/FileWriteSink.android.kt
@@ -0,0 +1,29 @@
+package ru.fromchat.core.files
+
+import java.io.BufferedOutputStream
+import java.io.File
+import java.io.FileOutputStream
+
+internal actual class FileWriteSink actual constructor(
+ path: String,
+ append: Boolean,
+) : AutoCloseable {
+ private val output = BufferedOutputStream(
+ FileOutputStream(File(path), append),
+ 256 * 1024,
+ )
+
+ actual fun write(buffer: ByteArray, offset: Int, length: Int) {
+ if (length <= 0) return
+ output.write(buffer, offset, length)
+ }
+
+ actual fun flush() {
+ output.flush()
+ }
+
+ actual override fun close() {
+ output.flush()
+ output.close()
+ }
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.android.kt
index c98fa33..ba5cac2 100644
--- a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.android.kt
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/backup/BackupCrypto.android.kt
@@ -84,4 +84,5 @@ object BackupCryptoPlatform {
cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec)
cipher.doFinal(ciphertext)
}
+
}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt
index 8396104..1a0f575 100644
--- a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.android.kt
@@ -50,6 +50,17 @@ actual object DmCrypto {
decryptAesGcmRaw(iv, ciphertext, mek)
}
+ actual suspend fun decryptAesGcmFileToPath(
+ ivB64: String,
+ encryptedFilePath: String,
+ mek: ByteArray,
+ outputPath: String,
+ ): Long = withContext(Dispatchers.Default) {
+ val iv = Base64.decode(ivB64)
+ require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" }
+ DmFileOps.aesGcmDecryptFileToPath(iv, encryptedFilePath, mek, outputPath)
+ }
+
private suspend fun decryptAesGcmRaw(iv: ByteArray, ciphertext: ByteArray, mek: ByteArray): ByteArray {
require(mek.size == AES_KEY_SIZE) { "MEK must be 32 bytes" }
require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" }
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/PlatformAesGcmStreamDecrypt.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/PlatformAesGcmStreamDecrypt.android.kt
new file mode 100644
index 0000000..9f5cb31
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/dm/PlatformAesGcmStreamDecrypt.android.kt
@@ -0,0 +1,70 @@
+package ru.fromchat.crypto.dm
+
+import org.bouncycastle.crypto.engines.AESEngine
+import org.bouncycastle.crypto.modes.GCMBlockCipher
+import org.bouncycastle.crypto.params.AEADParameters
+import org.bouncycastle.crypto.params.KeyParameter
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.FileInputStream
+import java.io.FileOutputStream
+
+private const val AES_KEY_SIZE = 32
+private const val GCM_IV_SIZE = 12
+private const val GCM_TAG_SIZE = 16
+private const val FILE_DECRYPT_BUFFER_BYTES = 256 * 1024
+
+/**
+ * Bouncy Castle AES-GCM streaming decrypt — matches server hazmat [encrypt_message_to_file]
+ * (ciphertext || tag). JCA [Cipher] buffers the full ciphertext in GCM decrypt mode and OOMs
+ * on large files; BC [GCMBlockCipher.processBytes] does not.
+ */
+internal actual suspend fun platformAesGcmStreamDecryptMekFile(
+ iv: ByteArray,
+ encryptedPath: String,
+ key: ByteArray,
+ outputPath: String,
+): Long {
+ require(key.size == AES_KEY_SIZE) { "MEK must be 32 bytes" }
+ require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" }
+
+ val inputFile = java.io.File(encryptedPath)
+ val outputFile = java.io.File(outputPath)
+ outputFile.parentFile?.mkdirs()
+
+ val encryptedSize = inputFile.length()
+ require(encryptedSize >= GCM_TAG_SIZE) { "Ciphertext too short" }
+
+ if (outputFile.exists()) {
+ outputFile.delete()
+ }
+
+ val cipher = GCMBlockCipher.newInstance(AESEngine())
+ cipher.init(false, AEADParameters(KeyParameter(key), 128, iv))
+
+ val inBuf = ByteArray(FILE_DECRYPT_BUFFER_BYTES)
+ val outBuf = ByteArray(FILE_DECRYPT_BUFFER_BYTES)
+ var plaintextBytes = 0L
+
+ BufferedInputStream(FileInputStream(inputFile)).use { input ->
+ BufferedOutputStream(FileOutputStream(outputFile)).use { output ->
+ while (true) {
+ val read = input.read(inBuf)
+ if (read <= 0) break
+ val outLen = cipher.processBytes(inBuf, 0, read, outBuf, 0)
+ if (outLen > 0) {
+ output.write(outBuf, 0, outLen)
+ plaintextBytes += outLen
+ }
+ }
+ val finalLen = cipher.doFinal(outBuf, 0)
+ if (finalLen > 0) {
+ output.write(outBuf, 0, finalLen)
+ plaintextBytes += finalLen
+ }
+ }
+ }
+
+ require(plaintextBytes > 0L) { "Decrypted file is empty" }
+ return plaintextBytes
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.android.kt
new file mode 100644
index 0000000..13a2024
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.android.kt
@@ -0,0 +1,32 @@
+package ru.fromchat.crypto.transport
+
+import java.io.File
+import java.io.FileOutputStream
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+actual object TransportFileEncryptor {
+ actual suspend fun encryptPlaintextFileToTransportBlob(
+ sourceUri: String,
+ destinationPath: String,
+ transportPublicKeyB64: String,
+ ephemeralSecretKey: ByteArray,
+ plaintextSizeBytes: Long,
+ onPlaintextProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)?,
+ ): Long = withContext(Dispatchers.IO) {
+ val dest = File(destinationPath)
+ dest.parentFile?.mkdirs()
+ dest.delete()
+ FileOutputStream(dest).use { output ->
+ encryptPlaintextFileToFcaeBlob(
+ sourceUri = sourceUri,
+ writeBytes = { bytes -> output.write(bytes) },
+ finish = { dest.length() },
+ transportPublicKeyB64 = transportPublicKeyB64,
+ ephemeralSecretKey = ephemeralSecretKey,
+ plaintextSizeBytes = plaintextSizeBytes,
+ onPlaintextProgress = onPlaintextProgress,
+ )
+ }
+ }
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.android.kt
new file mode 100644
index 0000000..423aea6
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.android.kt
@@ -0,0 +1,31 @@
+package ru.fromchat.crypto.transport
+
+import com.ionspin.kotlin.crypto.LibsodiumInitializer
+import com.ionspin.kotlin.crypto.box.Box
+import com.pr0gramm3r101.utils.crypto.Base64
+import ru.fromchat.crypto.backup.BackupCryptoPlatform
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+
+internal actual fun deriveTransportFileAesKey(
+ transportPublicKeyB64: String,
+ ephemeralSecretKey: ByteArray,
+): ByteArray {
+ if (!LibsodiumInitializer.isInitialized()) {
+ LibsodiumInitializer.initializeWithCallback { }
+ }
+ val transportPublicKey = Base64.decode(transportPublicKeyB64).toUByteArray()
+ val shared = Box.beforeNM(transportPublicKey, ephemeralSecretKey.toUByteArray()).toByteArray()
+ return hkdfTransportFileKey(shared)
+}
+
+internal actual suspend fun aesGcmEncryptChunk(
+ key: ByteArray,
+ plaintext: ByteArray,
+): Pair = BackupCryptoPlatform.aesGcmEncrypt(key, plaintext)
+
+internal actual fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
+ val mac = Mac.getInstance("HmacSHA256")
+ mac.init(SecretKeySpec(key, "HmacSHA256"))
+ return mac.doFinal(data)
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentDownloadForegroundService.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentDownloadForegroundService.kt
new file mode 100644
index 0000000..14fb25d
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentDownloadForegroundService.kt
@@ -0,0 +1,193 @@
+package ru.fromchat.download
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.runBlocking
+import org.jetbrains.compose.resources.getString
+import ru.fromchat.Res
+import ru.fromchat.notif_file_download_channel_name
+import ru.fromchat.notif_file_download_percent
+import ru.fromchat.notif_file_download_progress
+import ru.fromchat.notif_file_download_text
+import ru.fromchat.notif_file_download_title
+
+/**
+ * Foreground service for in-flight DM file attachment downloads (decrypt + cache).
+ */
+class AttachmentDownloadForegroundService : Service() {
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_STOP -> {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ ACTION_UPDATE -> {
+ val percent = intent.getIntExtra(EXTRA_PERCENT, -1)
+ val label = intent.getStringExtra(EXTRA_LABEL)
+ if (percent >= 0) {
+ updateNotification(percent, label)
+ }
+ }
+ ACTION_START, null -> startIfNeeded(intent)
+ }
+ return START_STICKY
+ }
+
+ private fun startIfNeeded(intent: Intent?) {
+ val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ val channelName = intent?.getStringExtra(EXTRA_CHANNEL_NAME)
+ ?: runBlocking { getString(Res.string.notif_file_download_channel_name) }
+ val title = intent?.getStringExtra(EXTRA_TITLE)
+ ?: runBlocking { getString(Res.string.notif_file_download_title) }
+ val defaultText = intent?.getStringExtra(EXTRA_DEFAULT_TEXT)
+ ?: runBlocking { getString(Res.string.notif_file_download_text) }
+ ensureChannel(nm, channelName)
+ cachedTitle = title
+ cachedDefaultText = defaultText
+ val percent = intent?.getIntExtra(EXTRA_PERCENT, 1) ?: 1
+ val label = intent?.getStringExtra(EXTRA_LABEL)
+ val notification = buildNotification(title, defaultText, percent, label)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
+ )
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ }
+
+ private fun updateNotification(percent: Int, label: String?) {
+ val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ val title = cachedTitle ?: return
+ val defaultText = cachedDefaultText ?: return
+ nm.notify(
+ NOTIFICATION_ID,
+ buildNotification(title, defaultText, percent.coerceIn(0, 100), label),
+ )
+ }
+
+ private fun buildNotification(
+ title: String,
+ defaultText: String,
+ percent: Int,
+ label: String?,
+ ): Notification {
+ val contentText = runBlocking {
+ val percentLabel = getString(
+ Res.string.notif_file_download_percent,
+ percent.coerceIn(0, 100),
+ )
+ if (!label.isNullOrBlank()) {
+ getString(Res.string.notif_file_download_progress, percentLabel, label)
+ } else {
+ percentLabel
+ }
+ }.let { resolved ->
+ if (percent > 0) resolved else defaultText
+ }
+ val builder = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(title)
+ .setContentText(contentText)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(Notification.CATEGORY_PROGRESS)
+ when (val p = percent.coerceIn(0, 100)) {
+ 0 -> builder.setProgress(100, 0, true)
+ else -> builder.setProgress(100, p, false)
+ }
+ return builder.build()
+ }
+
+ private fun ensureChannel(nm: NotificationManager, channelName: String) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ nm.createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW),
+ )
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "fromchat_file_download"
+ private const val NOTIFICATION_ID = 0xFC12
+
+ private const val ACTION_START = "ru.fromchat.download.AttachmentDownloadForegroundService.START"
+ private const val ACTION_STOP = "ru.fromchat.download.AttachmentDownloadForegroundService.STOP"
+ private const val ACTION_UPDATE = "ru.fromchat.download.AttachmentDownloadForegroundService.UPDATE"
+
+ private const val EXTRA_CHANNEL_NAME = "channel_name"
+ private const val EXTRA_TITLE = "title"
+ private const val EXTRA_DEFAULT_TEXT = "default_text"
+ private const val EXTRA_PERCENT = "percent"
+ private const val EXTRA_LABEL = "label"
+
+ private val activeKeys = mutableSetOf()
+ private var cachedTitle: String? = null
+ private var cachedDefaultText: String? = null
+ private var lastPercent: Int = 0
+ private var lastLabel: String? = null
+ private var lastNotifUpdateMs: Long = 0L
+ private const val NOTIFICATION_MIN_INTERVAL_MS = 1_000L
+
+ @Synchronized
+ fun onJobStarted(app: Context, storageKey: String) {
+ val wasEmpty = activeKeys.isEmpty()
+ activeKeys.add(storageKey)
+ if (!wasEmpty) return
+ val intent = Intent(app, AttachmentDownloadForegroundService::class.java).apply {
+ action = ACTION_START
+ putExtra(EXTRA_PERCENT, lastPercent.coerceAtLeast(1))
+ lastLabel?.let { putExtra(EXTRA_LABEL, it) }
+ }
+ ContextCompat.startForegroundService(app, intent)
+ }
+
+ @Synchronized
+ fun onJobFinished(app: Context, storageKey: String) {
+ activeKeys.remove(storageKey)
+ if (activeKeys.isNotEmpty()) return
+ val intent = Intent(app, AttachmentDownloadForegroundService::class.java).apply {
+ action = ACTION_STOP
+ }
+ app.startService(intent)
+ }
+
+ @Synchronized
+ fun updateProgress(app: Context, percent: Int, displayLabel: String?) {
+ val pct = percent.coerceIn(0, 100)
+ val now = System.currentTimeMillis()
+ val forceUpdate = pct <= 1 || pct >= 100
+ if (!forceUpdate && now - lastNotifUpdateMs < NOTIFICATION_MIN_INTERVAL_MS) {
+ return
+ }
+ lastPercent = pct
+ lastNotifUpdateMs = now
+ if (!displayLabel.isNullOrBlank()) {
+ lastLabel = displayLabel
+ }
+ if (activeKeys.isEmpty()) return
+ val intent = Intent(app, AttachmentDownloadForegroundService::class.java).apply {
+ action = ACTION_UPDATE
+ putExtra(EXTRA_PERCENT, lastPercent)
+ lastLabel?.let { putExtra(EXTRA_LABEL, it) }
+ }
+ app.startService(intent)
+ }
+ }
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentFileCopyForegroundService.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentFileCopyForegroundService.kt
new file mode 100644
index 0000000..e24e775
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/download/AttachmentFileCopyForegroundService.kt
@@ -0,0 +1,117 @@
+package ru.fromchat.download
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.runBlocking
+import org.jetbrains.compose.resources.getString
+import ru.fromchat.Res
+import ru.fromchat.notif_file_copy_channel_name
+import ru.fromchat.notif_file_copy_text
+import ru.fromchat.notif_file_copy_title
+
+/**
+ * Foreground service for copying decrypted attachments to a user-chosen destination (SAF).
+ */
+class AttachmentFileCopyForegroundService : Service() {
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_STOP -> {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ ACTION_START, null -> startIfNeeded(intent)
+ }
+ return START_STICKY
+ }
+
+ private fun startIfNeeded(intent: Intent?) {
+ val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ val channelName = intent?.getStringExtra(EXTRA_CHANNEL_NAME)
+ ?: runBlocking { getString(Res.string.notif_file_copy_channel_name) }
+ val title = intent?.getStringExtra(EXTRA_TITLE)
+ ?: runBlocking { getString(Res.string.notif_file_copy_title) }
+ val defaultText = intent?.getStringExtra(EXTRA_DEFAULT_TEXT)
+ ?: runBlocking { getString(Res.string.notif_file_copy_text) }
+ ensureChannel(nm, channelName)
+ val label = intent?.getStringExtra(EXTRA_LABEL)
+ val contentText = label?.takeIf { it.isNotBlank() } ?: defaultText
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(title)
+ .setContentText(contentText)
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(Notification.CATEGORY_PROGRESS)
+ .build()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
+ )
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ }
+
+ private fun ensureChannel(nm: NotificationManager, channelName: String) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ nm.createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW),
+ )
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "fromchat_file_copy"
+ private const val NOTIFICATION_ID = 0xFC13
+
+ private const val ACTION_START =
+ "ru.fromchat.download.AttachmentFileCopyForegroundService.START"
+ private const val ACTION_STOP =
+ "ru.fromchat.download.AttachmentFileCopyForegroundService.STOP"
+
+ private const val EXTRA_CHANNEL_NAME = "channel_name"
+ private const val EXTRA_TITLE = "title"
+ private const val EXTRA_DEFAULT_TEXT = "default_text"
+ private const val EXTRA_LABEL = "label"
+
+ private val activeKeys = mutableSetOf()
+
+ @Synchronized
+ fun onJobStarted(app: Context, storageKey: String, displayLabel: String? = null) {
+ val wasEmpty = activeKeys.isEmpty()
+ activeKeys.add(storageKey)
+ if (!wasEmpty) return
+ val intent = Intent(app, AttachmentFileCopyForegroundService::class.java).apply {
+ action = ACTION_START
+ displayLabel?.let { putExtra(EXTRA_LABEL, it) }
+ }
+ ContextCompat.startForegroundService(app, intent)
+ }
+
+ @Synchronized
+ fun onJobFinished(app: Context, storageKey: String) {
+ activeKeys.remove(storageKey)
+ if (activeKeys.isNotEmpty()) return
+ val intent = Intent(app, AttachmentFileCopyForegroundService::class.java).apply {
+ action = ACTION_STOP
+ }
+ app.startService(intent)
+ }
+ }
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileCopy.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileCopy.android.kt
new file mode 100644
index 0000000..1623efd
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileCopy.android.kt
@@ -0,0 +1,43 @@
+package ru.fromchat.ui.chat
+
+import android.net.Uri
+import com.pr0gramm3r101.utils.UtilsLibrary
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import ru.fromchat.api.AttachmentFileCopyForeground
+import java.io.File
+import java.io.FileInputStream
+
+internal suspend fun copyCachedFileToDestinationUri(
+ sourceCacheUri: String,
+ destinationUri: String,
+ storageKey: String,
+ displayFilename: String?,
+): Boolean = withContext(Dispatchers.IO) {
+ val sourceFile = uriToLocalCacheFile(sourceCacheUri) ?: return@withContext false
+ if (!sourceFile.isFile || sourceFile.length() <= 0L) return@withContext false
+ AttachmentFileCopyForeground.onCopyStarted(storageKey, displayFilename)
+ try {
+ runCatching {
+ val dest = Uri.parse(destinationUri)
+ UtilsLibrary.context.contentResolver.openOutputStream(dest, "w")?.use { out ->
+ FileInputStream(sourceFile).use { input ->
+ input.copyTo(out, bufferSize = 256 * 1024)
+ out.flush()
+ }
+ } != null && sourceFile.length() > 0L
+ }.getOrDefault(false)
+ } finally {
+ AttachmentFileCopyForeground.onCopyFinished(storageKey)
+ }
+}
+
+internal fun uriToLocalCacheFile(cacheUri: String): File? {
+ val path = when {
+ cacheUri.startsWith("file://") -> Uri.parse(cacheUri).path
+ else -> cacheUri
+ }?.trim().orEmpty()
+ if (path.isEmpty()) return null
+ val file = File(path)
+ return file.takeIf { it.isFile }
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileCopyWorker.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileCopyWorker.kt
new file mode 100644
index 0000000..fe4e7b7
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileCopyWorker.kt
@@ -0,0 +1,59 @@
+package ru.fromchat.ui.chat
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import com.pr0gramm3r101.utils.UtilsLibrary
+
+class AttachmentFileCopyWorker(
+ appContext: Context,
+ params: WorkerParameters,
+) : CoroutineWorker(appContext, params) {
+
+ override suspend fun doWork(): Result {
+ val storageKey = inputData.getString(KEY_STORAGE) ?: return Result.failure()
+ val entry = PendingFileSaveRegistry.listPending()
+ .firstOrNull { it.storageKey == storageKey }
+ ?: return Result.success()
+ val cacheUri = DecryptedFileCache.getCachedUriForStorageKey(storageKey)
+ ?: return Result.retry()
+ if (cachedAttachmentFileSize(cacheUri) <= 0L) return Result.retry()
+ val ok = copyCachedFileToDestinationUri(
+ sourceCacheUri = cacheUri,
+ destinationUri = entry.destinationUri,
+ storageKey = storageKey,
+ displayFilename = entry.filename,
+ )
+ return if (ok) {
+ PendingFileSaveRegistry.remove(storageKey)
+ Result.success()
+ } else {
+ Result.retry()
+ }
+ }
+
+ companion object {
+ private const val KEY_STORAGE = "storageKey"
+ private const val WORK_PREFIX = "attachment-file-copy-"
+
+ fun enqueue(storageKey: String) {
+ val context = UtilsLibrary.context
+ val request = OneTimeWorkRequestBuilder()
+ .setInputData(workDataOf(KEY_STORAGE to storageKey))
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ "$WORK_PREFIX$storageKey",
+ ExistingWorkPolicy.REPLACE,
+ request,
+ )
+ }
+ }
+}
+
+internal actual fun enqueuePlatformCopy(storageKey: String) {
+ AttachmentFileCopyWorker.enqueue(storageKey)
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.android.kt
index 2bc03e8..64bf29f 100644
--- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.android.kt
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.android.kt
@@ -65,6 +65,10 @@ actual fun rememberCreateDownloadDestinationLauncher(
}
}
+actual suspend fun persistExportUriPermissionIfNeeded(exportUri: String) {
+ persistExportUriPermission(exportUri)
+}
+
suspend fun persistExportUriPermission(exportUri: String) {
withContext(Dispatchers.IO) {
if (!exportUri.startsWith("content://")) return@withContext
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.android.kt
new file mode 100644
index 0000000..1d6dd2f
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.android.kt
@@ -0,0 +1,8 @@
+package ru.fromchat.ui.chat
+
+import android.widget.Toast
+import com.pr0gramm3r101.utils.UtilsLibrary
+
+internal actual fun showAttachmentOpenFailed(message: String) {
+ Toast.makeText(UtilsLibrary.context, message, Toast.LENGTH_SHORT).show()
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.android.kt
index ab823e0..2709ee6 100644
--- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.android.kt
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.android.kt
@@ -1,58 +1,106 @@
package ru.fromchat.ui.chat
+import android.app.Activity
+import android.content.ClipData
+import android.content.Context
+import android.content.ContextWrapper
import android.content.Intent
-import android.net.Uri
+import android.content.pm.PackageManager
+import android.os.Build
import com.pr0gramm3r101.utils.UtilsLibrary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
-import java.io.File
+import ru.fromchat.core.Logger
+import ru.fromchat.core.files.AttachmentFileProvider
-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 openCachedAttachmentFile(
+ cacheUri: String,
+ mimeType: String,
+ displayFilename: String?,
+): Boolean = withContext(Dispatchers.Main) {
+ val tag = "AttachmentOpen"
+ val appContext = UtilsLibrary.context
+ val context = findActivity(appContext) ?: appContext
+ val file = uriToLocalCacheFile(cacheUri) ?: return@withContext false
+ if (!file.exists() || file.length() <= 0L) return@withContext false
+ val contentUri = AttachmentFileProvider.uriForFile(appContext, file) ?: return@withContext false
+ val nameForMime = displayFilename?.takeIf { it.isNotBlank() }
+ ?: AttachmentFileProvider.displayNameFor(file)
+ val resolvedMime = mimeType.takeIf { it.isNotBlank() && mimeType != "application/octet-stream" }
+ ?: mimeTypeForFilename(nameForMime)
-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
+ fun commonFlags(intent: Intent): Intent = intent.apply {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ clipData = ClipData.newRawUri(nameForMime, contentUri)
+ if (context !is Activity) {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
- exportUri.startsWith("file://") -> {
- val path = Uri.parse(exportUri).path ?: return@runCatching false
- File(path).isFile
- }
- else -> File(exportUri).isFile
}
+
+ fun buildViewIntent(type: String): Intent =
+ Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(contentUri, type)
+ }.let(::commonFlags)
+
+ fun queryHandlerPackages(pm: PackageManager, intent: Intent): List {
+ val infos = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ pm.queryIntentActivities(
+ intent,
+ PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()),
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ pm.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
+ }
+ return infos.mapNotNull { it.activityInfo?.packageName }.distinct()
+ }
+
+ fun ensureGrantForIntent(intent: Intent): Boolean {
+ val pm = context.packageManager
+ val pkgs = queryHandlerPackages(pm, intent)
+ if (pkgs.isEmpty()) return false
+ pkgs.forEach { pkg ->
+ runCatching {
+ context.grantUriPermission(
+ pkg,
+ contentUri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
+ )
+ }
+ }
+ return true
+ }
+
+ // IMPORTANT: do NOT wrap in Intent.createChooser(...) here.
+ // Starting the raw ACTION_VIEW intent allows Android to:
+ // - open the default app when a default is set
+ // - show the system resolver with "Once/Always" when multiple apps can handle it
+ val primary = buildViewIntent(resolvedMime)
+ val fallback = buildViewIntent("*/*")
+ val hasPrimary = ensureGrantForIntent(primary)
+ val hasFallback = if (!hasPrimary) ensureGrantForIntent(fallback) else true
+ if (!hasPrimary && !hasFallback) {
+ Logger.w(tag, "No handler for uri=$contentUri mime=$resolvedMime name=$nameForMime")
+ return@runCatching false
+ }
+
+ try {
+ context.startActivity(primary)
+ Logger.d(tag, "startActivity ok mime=$resolvedMime uri=$contentUri name=$nameForMime")
+ } catch (t: Throwable) {
+ Logger.w(tag, "startActivity primary failed, falling back mime=$resolvedMime uri=$contentUri", t)
+ context.startActivity(fallback)
+ }
+ true
+ }.onFailure { t ->
+ Logger.e(tag, "openCachedAttachmentFile failed cacheUri=$cacheUri", t)
}.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)
+private tailrec fun findActivity(ctx: Context?): Activity? = when (ctx) {
+ is Activity -> ctx
+ is ContextWrapper -> findActivity(ctx.baseContext)
+ else -> null
}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.android.kt
new file mode 100644
index 0000000..3403bce
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.android.kt
@@ -0,0 +1,108 @@
+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.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import com.pr0gramm3r101.utils.UtilsLibrary
+import kotlinx.coroutines.launch
+
+private class CreateFileSaveContract : ActivityResultContract() {
+ override fun createIntent(context: Context, input: SavableMessageFile): Intent {
+ return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = input.mimeType
+ putExtra(Intent.EXTRA_TITLE, input.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 rememberPlatformSaveMessageFile(
+ onComplete: (Boolean) -> Unit,
+): (SavableMessageFile) -> Unit {
+ val scope = rememberCoroutineScope()
+ var pendingSavable by remember { mutableStateOf(null) }
+ val launcher = rememberLauncherForActivityResult(CreateFileSaveContract()) { destination ->
+ val pending = pendingSavable
+ pendingSavable = null
+ if (destination == null || pending == null) {
+ onComplete(false)
+ return@rememberLauncherForActivityResult
+ }
+ scope.launch {
+ runCatching {
+ val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ UtilsLibrary.context.contentResolver.takePersistableUriPermission(destination, flags)
+ }
+ val cacheUri = pending.cacheUri
+ if (cachedAttachmentFileSize(cacheUri) <= 0L) {
+ PendingFileSaveRegistry.schedule(
+ PendingFileSaveEntry(
+ storageKey = pending.storageKey,
+ destinationUri = destination.toString(),
+ filename = pending.filename,
+ mimeType = pending.mimeType,
+ clientMessageId = pending.clientMessageId,
+ ),
+ )
+ onComplete(false)
+ return@launch
+ }
+ val ok = copyCachedFileToDestinationUri(
+ sourceCacheUri = cacheUri,
+ destinationUri = destination.toString(),
+ storageKey = pending.storageKey,
+ displayFilename = pending.filename,
+ )
+ if (ok) {
+ PendingFileSaveRegistry.remove(pending.storageKey)
+ } else {
+ PendingFileSaveRegistry.schedule(
+ PendingFileSaveEntry(
+ storageKey = pending.storageKey,
+ destinationUri = destination.toString(),
+ filename = pending.filename,
+ mimeType = pending.mimeType,
+ clientMessageId = pending.clientMessageId,
+ ),
+ )
+ }
+ onComplete(ok)
+ }
+ }
+ return remember(launcher) {
+ { savable: SavableMessageFile ->
+ pendingSavable = savable
+ launcher.launch(savable)
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 0780724..b178904 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -74,7 +74,12 @@
Ответ %1$s
Сообщение не показывается
Не удалось загрузить
+ Не удалось отправить файл
+ Файл слишком большой для отправки на этом устройстве
+ Повторить отправку файла
Повторить
+ Не удалось открыть файл. Попробуйте «Сохранить» в меню сообщения.
+ Открыть с помощью
Повторить загрузку изображения
Правка сообщения
Ответить
@@ -84,6 +89,14 @@
Отменить
%1$d\u0025
%1$s · %2$s
+ Сохранение файла
+ Сохранение вложения
+ Копирование файла в фоне
+ Загрузка файла
+ Загрузка вложения
+ Загрузка продолжается в фоне
+ %1$d\u0025
+ %1$s · %2$s
Сохранить
Закрыть
Убрать
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index 7d6239b..fc5dede 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -87,7 +87,12 @@
Reply to %1$s
Can’t show this message
Failed to load
+ Couldn\'t send file
+ File is too large to send on this device
+ Retry sending file
Retry
+ Couldn\'t open this file. Try Save from the message menu.
+ Open with
Retry loading image
Edit message
@@ -100,6 +105,14 @@
Save
%1$d\u0025
%1$s · %2$s
+ Saving file
+ Saving attachment
+ Copying file in the background
+ File download
+ Downloading attachment
+ Download continues in the background
+ %1$d\u0025
+ %1$s · %2$s
Close
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
index 5362667..d5c8d4c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -18,13 +18,8 @@ import io.ktor.client.plugins.websocket.pingInterval
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.delete
import io.ktor.client.request.get
-import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
-import io.ktor.http.contentLength
-import io.ktor.utils.io.core.isEmpty
-import io.ktor.utils.io.core.readBytes
-import io.ktor.utils.io.readRemaining
import com.pr0gramm3r101.utils.files.PlatformFileSystem
import io.ktor.client.request.patch
import io.ktor.client.request.parameter
@@ -586,105 +581,97 @@ object ApiClient {
else -> "${Config.apiBaseUrl}$path"
}
- suspend fun fetchEncryptedFile(path: String): ByteArray =
- fetchEncryptedFileResumable(path, resumeKey = null, onProgress = null)
+ /** Encrypted ciphertext stored on disk after a resumable download. */
+ data class EncryptedFileOnDisk(
+ val path: String,
+ val sizeBytes: Long,
+ )
/**
- * Downloads encrypted file bytes with optional resume ([resumeKey] partial on disk) and progress.
+ * Downloads encrypted ciphertext to disk with optional resume ([resumeKey] partial on disk) and progress.
*/
suspend fun fetchEncryptedFileResumable(
path: String,
resumeKey: String?,
onProgress: ((percent: Int) -> Unit)?,
- ): ByteArray {
+ ): EncryptedFileOnDisk {
+ resumeKey?.let { anchorPartialDownloadMetaIfNeeded(it) }
val url = encryptedFileUrl(path)
- val partialPath = resumeKey?.let { partialEncryptedDownloadPath(it) }
- val prefix = partialPath?.let { readPartialEncryptedBytes(it) } ?: ByteArray(0)
- val offset = prefix.size
-
- onProgress?.invoke(if (offset > 0) percentForBytes(offset, offset.coerceAtLeast(1)) else 1)
-
- val response = http.get(url) {
- if (offset > 0) {
- header(HttpHeaders.Range, "bytes=$offset-")
- }
+ val outputPath = resumeKey?.let { partialEncryptedDownloadPath(it) }
+ ?: oneOffEncryptedDownloadPath()
+ ?: error("Encrypted downloads directory unavailable")
+ val offset = if (PlatformFileSystem.exists(outputPath)) {
+ PlatformFileSystem.fileSize(outputPath)
+ } else {
+ 0L
}
- return when (response.status) {
- HttpStatusCode.PartialContent -> {
- readDownloadBody(
- response = response,
- prefix = prefix,
- partialPath = partialPath,
- onProgress = onProgress,
- )
- }
- HttpStatusCode.OK -> {
- if (offset > 0) {
- partialPath?.let { PlatformFileSystem.delete(it) }
+ val resumePercent = if (offset > 0L) {
+ resumeKey?.let { loadPartialDownloadPercent(it) }
+ ?: percentForBytes(offset, offset.coerceAtLeast(1L))
+ } else {
+ 1
+ }
+ var lastReportedPercent = -1
+ fun reportProgress(percent: Int) {
+ val pct = percent.coerceIn(0, 100)
+ if (pct == lastReportedPercent && pct !in setOf(0, 100)) return
+ lastReportedPercent = pct
+ onProgress?.invoke(pct)
+ }
+
+ reportProgress(resumePercent.coerceIn(1, 99))
+
+ var expectedTotalBytes: Long? = null
+ val received = streamEncryptedFileToDisk(
+ url = url,
+ outputPath = outputPath,
+ rangeOffset = offset,
+ bearerToken = token,
+ userAgent = currentDownloadUserAgent(),
+ onChunkReceived = { receivedBytes, totalBytes ->
+ if (totalBytes != null && totalBytes > 0L) {
+ expectedTotalBytes = totalBytes
}
- readDownloadBody(
- response = response,
- prefix = if (offset > 0) ByteArray(0) else prefix,
- partialPath = partialPath,
- onProgress = onProgress,
- )
- }
- else -> {
- val bytes = response.body()
- onProgress?.invoke(100)
- partialPath?.let { PlatformFileSystem.delete(it) }
- bytes
- }
- }
- }
-
- private suspend fun readDownloadBody(
- response: HttpResponse,
- prefix: ByteArray,
- partialPath: String?,
- onProgress: ((percent: Int) -> Unit)?,
- ): ByteArray {
- val channel = response.bodyAsChannel()
- var buffer = prefix
- var received = prefix.size
- val totalBytes = responseTotalBytes(response, received)
-
- while (!channel.isClosedForRead) {
- val packet = channel.readRemaining(16 * 1024)
- if (packet.isEmpty) break
- val chunk = packet.readBytes()
- if (chunk.isEmpty()) continue
- buffer = buffer + chunk
- received += chunk.size
- partialPath?.let { PlatformFileSystem.writeBytes(it, buffer) }
- onProgress?.invoke(
- if (totalBytes != null && totalBytes > 0) {
- percentForBytes(received, totalBytes)
+ val percent = if (totalBytes != null && totalBytes > 0L) {
+ percentForBytes(receivedBytes, totalBytes)
} else {
- (received / 32_768).coerceIn(1, 99)
- },
- )
- }
+ (receivedBytes / 32_768L).toInt().coerceIn(1, 99)
+ }
+ resumeKey?.let {
+ savePartialDownloadProgress(
+ it,
+ percent,
+ totalBytes?.coerceAtMost(Int.MAX_VALUE.toLong())?.toInt(),
+ )
+ }
+ reportProgress(percent)
+ },
+ )
- onProgress?.invoke(100)
- partialPath?.let { PlatformFileSystem.delete(it) }
- return buffer
+ expectedTotalBytes?.let { total ->
+ if (total > 0L && received < total) {
+ error("Encrypted download incomplete ($received of $total bytes): $path")
+ }
+ }
+ reportProgress(99)
+ resumeKey?.let { clearPartialDownloadMeta(it) }
+ return EncryptedFileOnDisk(outputPath, received)
}
- private fun responseTotalBytes(response: HttpResponse, receivedSoFar: Int): Int? {
- val contentRange = response.headers[HttpHeaders.ContentRange]
- if (contentRange != null) {
- val total = contentRange.substringAfterLast('/').toLongOrNull()
- if (total != null && total > 0L) return total.toInt()
- }
- val contentLength = response.contentLength()?.toInt()
- return when {
- response.status == HttpStatusCode.PartialContent && contentLength != null ->
- receivedSoFar + contentLength
- contentLength != null && contentLength > 0 -> contentLength
- else -> null
- }
+ private fun currentDownloadUserAgent(): String? {
+ val currentDevice = currentDeviceInfo()
+ return buildLoginUserAgent(
+ osName = currentDevice.osName?.takeIf { it.isNotBlank() },
+ osVersion = currentDevice.osVersion?.takeIf { it.isNotBlank() },
+ model = currentDevice.model?.takeIf { it.isNotBlank() },
+ brand = currentDevice.brand?.takeIf { it.isNotBlank() },
+ )
+ }
+
+ private fun oneOffEncryptedDownloadPath(): String? {
+ val dir = encryptedDownloadsDir() ?: return null
+ return "$dir/once_${kotlin.random.Random.nextLong()}.enc"
}
/** Drops a partial encrypted download so the next attempt starts clean. */
@@ -692,26 +679,315 @@ object ApiClient {
partialEncryptedDownloadPath(resumeKey)?.let { path ->
runCatching { PlatformFileSystem.delete(path) }
}
+ clearPartialDownloadMeta(resumeKey)
}
- private fun partialEncryptedDownloadPath(resumeKey: String): String? {
+ fun hasPartialEncryptedDownload(resumeKey: String): Boolean {
+ val path = partialEncryptedDownloadPath(resumeKey) ?: return false
+ return PlatformFileSystem.exists(path)
+ }
+
+ /** True when encrypted partial bytes exist on disk and can be resumed (cancel or abrupt kill). */
+ fun hasResumablePartialOnDisk(resumeKey: String): Boolean =
+ hasPartialEncryptedDownload(resumeKey)
+
+ fun loadPartialDownloadPercent(resumeKey: String): Int? =
+ partialDownloadMetaCache[resumeKey]?.percent
+
+ fun isPartialDownloadPaused(resumeKey: String): Boolean =
+ partialDownloadMetaCache[resumeKey]?.paused == true
+
+ fun isPartialDownloadUserDismissed(resumeKey: String): Boolean =
+ partialDownloadMetaCache[resumeKey]?.userDismissed == true
+
+ fun markPartialDownloadUserDismissed(resumeKey: String, dismissed: Boolean) {
+ val existing = partialDownloadMetaCache[resumeKey]
+ val percent = existing?.percent
+ ?: loadPartialDownloadPercent(resumeKey)
+ ?: 1
+ val meta = PartialDownloadMeta(
+ resumeKey = resumeKey,
+ percent = percent,
+ totalBytes = existing?.totalBytes,
+ paused = dismissed || existing?.paused == true,
+ userDismissed = dismissed,
+ )
+ partialDownloadMetaCache[resumeKey] = meta
+ writePartialDownloadMetaToDisk(meta)
+ val index = pausedDownloadIndexCache.toMutableSet()
+ if (dismissed || hasResumablePartialOnDisk(resumeKey)) {
+ index.add(resumeKey)
+ } else {
+ index.remove(resumeKey)
+ }
+ pausedDownloadIndexCache = index
+ writePausedDownloadIndexToDisk(index)
+ }
+
+ fun savePartialDownloadProgress(
+ resumeKey: String,
+ percent: Int,
+ totalBytes: Int? = null,
+ ) {
+ val existing = partialDownloadMetaCache[resumeKey]
+ val pct = percent.coerceIn(1, 99)
+ val total = totalBytes ?: existing?.totalBytes
+ if (existing != null && existing.percent == pct && existing.totalBytes == total && !existing.paused) {
+ return
+ }
+ val meta = PartialDownloadMeta(
+ resumeKey = resumeKey,
+ percent = pct,
+ totalBytes = total,
+ paused = existing?.paused == true,
+ userDismissed = existing?.userDismissed == true,
+ )
+ partialDownloadMetaCache[resumeKey] = meta
+ writePartialDownloadMetaToDisk(meta)
+ }
+
+ fun markPartialDownloadPaused(resumeKey: String, paused: Boolean) {
+ val existing = partialDownloadMetaCache[resumeKey]
+ val percent = existing?.percent ?: 1
+ val meta = PartialDownloadMeta(
+ resumeKey = resumeKey,
+ percent = percent,
+ totalBytes = existing?.totalBytes,
+ paused = paused,
+ userDismissed = existing?.userDismissed == true,
+ )
+ partialDownloadMetaCache[resumeKey] = meta
+ writePartialDownloadMetaToDisk(meta)
+ val index = pausedDownloadIndexCache.toMutableSet()
+ if (paused || meta.userDismissed) {
+ index.add(resumeKey)
+ } else {
+ index.remove(resumeKey)
+ }
+ pausedDownloadIndexCache = index
+ writePausedDownloadIndexToDisk(index)
+ }
+
+ /** Loads partial download metadata from disk (survives abrupt process death). */
+ suspend fun hydratePausedDownloadsFromDisk() {
+ val dir = encryptedDownloadsDir() ?: return
+ partialDownloadMetaCache.clear()
+ val resumableKeys = linkedSetOf()
+
+ for (name in PlatformFileSystem.listFileNamesInDirectory(dir)) {
+ if (!name.endsWith(".meta")) continue
+ val meta = readPartialDownloadMetaFileFromDisk("$dir/$name") ?: continue
+ if (!hasPartialEncryptedDownload(meta.resumeKey)) {
+ clearPartialDownloadMeta(meta.resumeKey)
+ continue
+ }
+ val interrupted = if (meta.userDismissed) {
+ meta
+ } else {
+ meta.copy(paused = true)
+ }
+ partialDownloadMetaCache[meta.resumeKey] = interrupted
+ writePartialDownloadMetaToDisk(interrupted)
+ resumableKeys.add(meta.resumeKey)
+ }
+
+ for (name in PlatformFileSystem.listFileNamesInDirectory(dir)) {
+ if (!name.startsWith("partial_") || !name.endsWith(".enc")) continue
+ val encPath = "$dir/$name"
+ val sizeBytes = PlatformFileSystem.fileSize(encPath)
+ if (sizeBytes <= 0L) {
+ runCatching { PlatformFileSystem.delete(encPath) }
+ continue
+ }
+ val safe = name.removePrefix("partial_").removeSuffix(".enc")
+ val resumeKey = partialDownloadMetaCache.entries.firstOrNull { entry ->
+ partialEncryptedDownloadPath(entry.key)?.substringAfterLast('/') == name
+ }?.key ?: recoverResumeKeyFromSafeName(safe, dir)
+ if (resumeKey == null) continue
+ if (resumeKey in resumableKeys) continue
+ val percent = (sizeBytes / 32_768L).toInt().coerceIn(1, 99)
+ val recovered = PartialDownloadMeta(
+ resumeKey = resumeKey,
+ percent = percent,
+ totalBytes = null,
+ paused = true,
+ userDismissed = false,
+ )
+ partialDownloadMetaCache[resumeKey] = recovered
+ writePartialDownloadMetaToDisk(recovered)
+ resumableKeys.add(resumeKey)
+ }
+
+ pausedDownloadIndexCache = resumableKeys
+ writePausedDownloadIndexToDisk(resumableKeys)
+ }
+
+ suspend fun hydratePartialMetaIfNeeded(resumeKey: String) {
+ if (partialDownloadMetaCache.containsKey(resumeKey)) return
+ readPartialDownloadMetaFromDisk(resumeKey)?.let { partialDownloadMetaCache[resumeKey] = it }
+ }
+
+ suspend fun anchorPartialDownloadMetaIfNeeded(resumeKey: String) {
+ hydratePartialMetaIfNeeded(resumeKey)
+ if (partialDownloadMetaCache.containsKey(resumeKey)) return
+ if (!hasPartialEncryptedDownload(resumeKey)) {
+ savePartialDownloadProgress(resumeKey, percent = 1, totalBytes = null)
+ return
+ }
+ val path = partialEncryptedDownloadPath(resumeKey) ?: return
+ val sizeBytes = PlatformFileSystem.fileSize(path)
+ if (sizeBytes <= 0L) return
+ val percent = (sizeBytes / 32_768L).toInt().coerceIn(1, 99)
+ val recovered = PartialDownloadMeta(
+ resumeKey = resumeKey,
+ percent = percent,
+ totalBytes = null,
+ paused = true,
+ userDismissed = false,
+ )
+ partialDownloadMetaCache[resumeKey] = recovered
+ writePartialDownloadMetaToDisk(recovered)
+ val index = pausedDownloadIndexCache.toMutableSet()
+ index.add(resumeKey)
+ pausedDownloadIndexCache = index
+ writePausedDownloadIndexToDisk(index)
+ }
+
+ /** All storage keys with a resumable partial on disk. */
+ fun listResumablePartialDownloadKeys(): List =
+ pausedDownloadIndexCache.filter { hasResumablePartialOnDisk(it) }
+
+ fun listAutoResumablePartialDownloadKeys(): List =
+ listResumablePartialDownloadKeys().filter { !isPartialDownloadUserDismissed(it) }
+
+ private suspend fun recoverResumeKeyFromSafeName(safe: String, dir: String): String? {
+ val metaName = "partial_$safe.meta"
+ if (!PlatformFileSystem.listFileNamesInDirectory(dir).contains(metaName)) return null
+ return readPartialDownloadMetaFileFromDisk("$dir/$metaName")?.resumeKey
+ }
+
+ private data class PartialDownloadMeta(
+ val resumeKey: String,
+ val percent: Int,
+ val totalBytes: Int?,
+ val paused: Boolean,
+ /** User tapped cancel; keep partial + meta but do not auto-resume on next app start. */
+ val userDismissed: Boolean = false,
+ )
+
+ private val partialDownloadMetaCache = mutableMapOf()
+ private var pausedDownloadIndexCache: Set = emptySet()
+
+ private suspend fun readPartialDownloadMetaFromDisk(resumeKey: String): PartialDownloadMeta? {
+ val path = partialDownloadMetaPath(resumeKey) ?: return null
+ return readPartialDownloadMetaFileFromDisk(path)
+ }
+
+ private suspend fun readPartialDownloadMetaFileFromDisk(path: String): PartialDownloadMeta? {
+ if (!PlatformFileSystem.exists(path)) return null
+ val text = runCatching {
+ ru.fromchat.core.cache.readOutboundFileBytes("file://$path").decodeToString()
+ }.getOrNull() ?: return null
+ return parsePartialDownloadMeta(text)
+ }
+
+ private fun parsePartialDownloadMeta(text: String): PartialDownloadMeta? {
+ var key: String? = null
+ var percent: Int? = null
+ var total: Int? = null
+ var paused = false
+ var userDismissed = false
+ for (line in text.lineSequence()) {
+ when {
+ line.startsWith("key=") -> key = line.removePrefix("key=").trim()
+ line.startsWith("percent=") -> percent = line.removePrefix("percent=").trim().toIntOrNull()
+ line.startsWith("total=") -> total = line.removePrefix("total=").trim().toIntOrNull()
+ line.startsWith("paused=1") -> paused = true
+ line.startsWith("dismissed=1") -> userDismissed = true
+ }
+ }
+ val resumeKey = key?.takeIf { it.isNotEmpty() } ?: return null
+ val pct = percent?.coerceIn(1, 99) ?: return null
+ return PartialDownloadMeta(resumeKey, pct, total, paused, userDismissed)
+ }
+
+ private fun writePartialDownloadMetaToDisk(meta: PartialDownloadMeta) {
+ val path = partialDownloadMetaPath(meta.resumeKey) ?: return
+ val lines = buildList {
+ add("key=${meta.resumeKey}")
+ add("percent=${meta.percent.coerceIn(1, 99)}")
+ meta.totalBytes?.let { add("total=$it") }
+ if (meta.paused) add("paused=1")
+ if (meta.userDismissed) add("dismissed=1")
+ }
+ runCatching {
+ PlatformFileSystem.writeBytes(path, lines.joinToString("\n").encodeToByteArray())
+ }
+ }
+
+ private fun clearPartialDownloadMeta(resumeKey: String) {
+ partialDownloadMetaCache.remove(resumeKey)
+ partialDownloadMetaPath(resumeKey)?.let { path ->
+ runCatching { PlatformFileSystem.delete(path) }
+ }
+ val index = pausedDownloadIndexCache.toMutableSet()
+ if (index.remove(resumeKey)) {
+ pausedDownloadIndexCache = index
+ writePausedDownloadIndexToDisk(index)
+ }
+ }
+
+ private fun pausedDownloadIndexPath(): String? {
+ val dir = encryptedDownloadsDir() ?: return null
+ return "$dir/paused_keys.txt"
+ }
+
+ private suspend fun readPausedDownloadIndexFromDisk(): Set {
+ val path = pausedDownloadIndexPath() ?: return emptySet()
+ if (!PlatformFileSystem.exists(path)) return emptySet()
+ return runCatching {
+ ru.fromchat.core.cache.readOutboundFileBytes("file://$path")
+ .decodeToString()
+ .lineSequence()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .toSet()
+ }.getOrElse { emptySet() }
+ }
+
+ private fun writePausedDownloadIndexToDisk(keys: Set) {
+ val path = pausedDownloadIndexPath() ?: return
+ if (keys.isEmpty()) {
+ runCatching { PlatformFileSystem.delete(path) }
+ return
+ }
+ runCatching {
+ PlatformFileSystem.writeBytes(path, keys.joinToString("\n").encodeToByteArray())
+ }
+ }
+
+ private fun encryptedDownloadsDir(): String? {
val base = PlatformFileSystem.getAppCacheDirectory()
if (base.isEmpty()) return null
val dir = "$base/encrypted_downloads"
PlatformFileSystem.ensureDirectory(dir)
+ return dir
+ }
+
+ private fun partialEncryptedDownloadPath(resumeKey: String): String? {
+ val dir = encryptedDownloadsDir() ?: return null
val safe = resumeKey.replace(Regex("[^a-zA-Z0-9._-]"), "_")
return "$dir/partial_$safe.enc"
}
- private suspend fun readPartialEncryptedBytes(path: String): ByteArray? {
- if (!PlatformFileSystem.exists(path)) return null
- return runCatching {
- ru.fromchat.core.cache.readOutboundFileBytes("file://$path")
- }.getOrNull()?.takeIf { it.isNotEmpty() }
+ private fun partialDownloadMetaPath(resumeKey: String): String? {
+ val dir = encryptedDownloadsDir() ?: return null
+ val safe = resumeKey.replace(Regex("[^a-zA-Z0-9._-]"), "_")
+ return "$dir/partial_$safe.meta"
}
- private fun percentForBytes(received: Int, total: Int): Int {
- if (received <= 0 || total <= 0) return 0
+ private fun percentForBytes(received: Long, total: Long): Int {
+ if (received <= 0L || total <= 0L) return 0
val raw = ((received.toDouble() / total.toDouble()) * 100.0).toInt()
return when {
raw <= 0 -> 1
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.kt
new file mode 100644
index 0000000..fcdad19
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.kt
@@ -0,0 +1,11 @@
+package ru.fromchat.api
+
+/**
+ * Android: keeps DM file attachment downloads alive in the background via a foreground service.
+ * No-op on other platforms.
+ */
+expect object AttachmentDownloadForeground {
+ fun onFileDownloadStarted(storageKey: String)
+ fun onFileDownloadProgress(percent: Int, displayLabel: String?)
+ fun onFileDownloadFinished(storageKey: String)
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt
index 726e053..04096d5 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt
@@ -10,8 +10,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.ui.chat.AttachmentMediaLog
+import ru.fromchat.ui.chat.DecryptedFileCache
import ru.fromchat.ui.chat.DecryptedImageCache
+import ru.fromchat.ui.chat.DmFileDownloader
import ru.fromchat.ui.chat.DownloadedFileRegistry
sealed class AttachmentDownloadProgress {
@@ -28,6 +31,12 @@ sealed class AttachmentDownloadProgress {
* [progressPercentByKey] is the source of truth for UI; [progressFlow] is for one-shot side effects.
*/
object AttachmentDownloadNotifier {
+ private var inFlightCheck: (String) -> Boolean = { false }
+
+ internal fun bindInFlightCheck(check: (String) -> Boolean) {
+ inFlightCheck = check
+ }
+
private val _progressFlow = MutableSharedFlow(extraBufferCapacity = 64)
val progressFlow: SharedFlow = _progressFlow
@@ -37,7 +46,12 @@ object AttachmentDownloadNotifier {
private val _failedKeys = MutableStateFlow>(emptySet())
val failedKeys: StateFlow> = _failedKeys.asStateFlow()
+ private val _cancelledKeys = MutableStateFlow>(emptySet())
+ val cancelledKeys: StateFlow> = _cancelledKeys.asStateFlow()
+
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private val resumeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private val progressThrottleByKey = mutableMapOf()
fun emit(
progress: AttachmentDownloadProgress,
@@ -53,24 +67,15 @@ object AttachmentDownloadNotifier {
is AttachmentDownloadProgress.Success -> progress.storageKey
is AttachmentDownloadProgress.Failed -> progress.storageKey
}
- val mirrorKeys = when {
- mirrorAsFileAttachment || primaryKey.startsWith("file_") ->
- DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
- else ->
- DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
- }.ifEmpty { listOf(primaryKey) }
+ val mirrorKeys = mirrorKeysFor(
+ primaryKey = primaryKey,
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = mirrorAsFileAttachment,
+ )
when (progress) {
- is AttachmentDownloadProgress.InProgress -> {
- if (progress.percent == 1 || progress.percent % 15 == 0 || progress.percent >= 95) {
- AttachmentMediaLog.download(
- "progress",
- "key" to progress.storageKey,
- "pct" to progress.percent,
- "msg" to msg,
- "mirror" to mirrorKeys.joinToString(","),
- )
- }
- }
+ is AttachmentDownloadProgress.InProgress -> Unit
is AttachmentDownloadProgress.Success ->
AttachmentMediaLog.download(
"success",
@@ -89,16 +94,47 @@ object AttachmentDownloadNotifier {
when (progress) {
is AttachmentDownloadProgress.InProgress -> {
val pct = progress.percent.coerceIn(1, 100)
- _progressPercentByKey.update { map ->
- map + mirrorKeys.associateWith { pct }
+ val throttle = throttleFor(mirrorKeys)
+ val publishUi = throttle.shouldPublishUi(pct)
+ val publishNotif = mirrorAsFileAttachment && throttle.shouldPublishNotification(pct)
+ if (publishUi || publishNotif) {
+ if (pct == 1 || pct % 15 == 0 || pct >= 95) {
+ AttachmentMediaLog.download(
+ "progress",
+ "key" to progress.storageKey,
+ "pct" to pct,
+ "msg" to msg,
+ "mirror" to mirrorKeys.joinToString(","),
+ )
+ }
+ }
+ if (publishUi) {
+ _progressPercentByKey.update { map ->
+ map + mirrorKeys.associateWith { pct }
+ }
+ }
+ if (publishNotif) {
+ AttachmentDownloadForeground.onFileDownloadProgress(
+ percent = pct,
+ displayLabel = messageLabel,
+ )
}
}
is AttachmentDownloadProgress.Success -> {
- _progressPercentByKey.update { map ->
- map + mirrorKeys.associateWith { 100 }
+ mirrorKeys.forEach { progressThrottleByKey.remove(it) }
+ _progressPercentByKey.update { map -> map - mirrorKeys.toSet() }
+ _cancelledKeys.update { cancelled -> cancelled - mirrorKeys.toSet() }
+ _failedKeys.update { failed -> failed - mirrorKeys.toSet() }
+ mirrorKeys.forEach { ApiClient.markPartialDownloadUserDismissed(it, dismissed = false) }
+ if (mirrorAsFileAttachment) {
+ AttachmentDownloadForeground.onFileDownloadProgress(
+ percent = 100,
+ displayLabel = messageLabel,
+ )
}
}
is AttachmentDownloadProgress.Failed -> {
+ mirrorKeys.forEach { progressThrottleByKey.remove(it) }
_progressPercentByKey.update { map -> map - mirrorKeys.toSet() }
_failedKeys.update { keys -> keys + mirrorKeys.toSet() }
}
@@ -114,13 +150,185 @@ object AttachmentDownloadNotifier {
clientMessageId: String? = null,
mirrorAsFileAttachment: Boolean = false,
) {
- val keys = if (mirrorAsFileAttachment) {
- DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
- } else {
- DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
- }.toSet()
+ val keys = lookupKeys(messageId, fileIndex, clientMessageId, mirrorAsFileAttachment).toSet()
_progressPercentByKey.update { map -> map - keys }
_failedKeys.update { failed -> failed - keys }
+ _cancelledKeys.update { cancelled -> cancelled - keys }
+ keys.forEach { ApiClient.markPartialDownloadUserDismissed(it, dismissed = false) }
+ }
+
+ /**
+ * Prepares a new download or resumes a paused partial. Clears stale data only when not resuming.
+ */
+ fun beginDownload(
+ messageId: Int,
+ fileIndex: Int,
+ clientMessageId: String? = null,
+ mirrorAsFileAttachment: Boolean = false,
+ ) {
+ val keys = lookupKeys(messageId, fileIndex, clientMessageId, mirrorAsFileAttachment)
+ val resuming = keys.any { ApiClient.hasResumablePartialOnDisk(it) }
+ _cancelledKeys.update { cancelled -> cancelled - keys.toSet() }
+ _failedKeys.update { failed -> failed - keys.toSet() }
+ if (resuming) {
+ val percent = keys.mapNotNull { ApiClient.loadPartialDownloadPercent(it) }.maxOrNull()
+ ?.coerceIn(1, 99)
+ ?: 1
+ applyProgressPercent(keys, percent)
+ keys.forEach {
+ ApiClient.markPartialDownloadPaused(it, paused = false)
+ ApiClient.markPartialDownloadUserDismissed(it, dismissed = false)
+ }
+ } else {
+ keys.forEach { ApiClient.clearPartialEncryptedDownload(it) }
+ applyProgressPercent(keys, 1)
+ }
+ }
+
+ /**
+ * Stops UI progress and marks the download paused. Partial encrypted bytes stay on disk for resume.
+ */
+ fun cancelDownload(
+ messageId: Int,
+ fileIndex: Int,
+ clientMessageId: String? = null,
+ mirrorAsFileAttachment: Boolean = false,
+ ) {
+ val keys = lookupKeys(messageId, fileIndex, clientMessageId, mirrorAsFileAttachment)
+ val percent = keys.mapNotNull { _progressPercentByKey.value[it] }.maxOrNull()
+ ?: keys.mapNotNull { ApiClient.loadPartialDownloadPercent(it) }.maxOrNull()
+ ?: 1
+ keys.forEach { key ->
+ ApiClient.savePartialDownloadProgress(key, percent)
+ ApiClient.markPartialDownloadUserDismissed(key, dismissed = true)
+ }
+ _progressPercentByKey.update { map -> map - keys.toSet() }
+ _failedKeys.update { failed -> failed - keys.toSet() }
+ _cancelledKeys.update { cancelled -> cancelled + keys.toSet() }
+ }
+
+ suspend fun restorePausedForAttachment(
+ messageId: Int,
+ fileIndex: Int,
+ clientMessageId: String? = null,
+ mirrorAsFileAttachment: Boolean = false,
+ ) {
+ val keys = lookupKeys(messageId, fileIndex, clientMessageId, mirrorAsFileAttachment)
+ keys.forEach { key ->
+ if (ApiClient.hasResumablePartialOnDisk(key)) {
+ ApiClient.anchorPartialDownloadMetaIfNeeded(key)
+ }
+ }
+ val resumable = keys.filter { ApiClient.hasResumablePartialOnDisk(it) }
+ if (resumable.isEmpty()) return
+
+ val percent = resumable.mapNotNull { ApiClient.loadPartialDownloadPercent(it) }.maxOrNull()
+ ?.coerceIn(1, 99)
+ ?: return
+ applyProgressPercent(keys, percent)
+
+ val dismissed = resumable.filter { ApiClient.isPartialDownloadUserDismissed(it) }
+ if (dismissed.isNotEmpty()) {
+ val activeDismissed = dismissed.filter { inFlightCheck(it) }.toSet()
+ _cancelledKeys.update { cancelled ->
+ (cancelled - dismissed.toSet()) + (dismissed.toSet() - activeDismissed)
+ }
+ }
+ }
+
+ suspend fun hydrateFromDisk() {
+ ApiClient.hydratePausedDownloadsFromDisk()
+ val dismissed = ApiClient.listResumablePartialDownloadKeys()
+ .filter { ApiClient.isPartialDownloadUserDismissed(it) }
+ if (dismissed.isNotEmpty()) {
+ _cancelledKeys.update { cancelled -> cancelled + dismissed.toSet() }
+ }
+ }
+
+ /** @deprecated Use [hydrateFromDisk] + [AttachmentTransferBootstrap.runColdStart]. */
+ suspend fun restoreAllPausedFromDisk() = hydrateFromDisk()
+
+ suspend fun resumeInterruptedDownloadsOnAppStart() {
+ val keys = ApiClient.listAutoResumablePartialDownloadKeys()
+ if (keys.isEmpty()) return
+ val currentUserId = ApiClient.user?.id
+ for (storageKey in keys.distinct()) {
+ val resolved = MessageCacheStore.findMessageForAttachmentStorageKey(storageKey) ?: continue
+ val message = resolved.message
+ val fileIndex = resolved.fileIndex
+ val file = message.files?.getOrNull(fileIndex) ?: continue
+ val envelope = message.dmEnvelope ?: continue
+ val clientMessageId = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
+ val mirrorAsFile = storageKey.startsWith("file_")
+ if (mirrorAsFile) {
+ if (DecryptedFileCache.getCached(message.id, fileIndex, clientMessageId) != null) continue
+ } else {
+ if (DecryptedImageCache.getCached(message.id, fileIndex, clientMessageId) != null) continue
+ }
+ beginDownload(
+ messageId = message.id,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = mirrorAsFile,
+ )
+ resumeScope.launch {
+ runCatching {
+ if (mirrorAsFile) {
+ DmFileDownloader.downloadToCache(
+ messageId = message.id,
+ fileIndex = fileIndex,
+ file = file,
+ envelope = envelope,
+ currentUserId = currentUserId,
+ clientMessageId = clientMessageId,
+ )
+ } else {
+ DecryptedImageCache.getOrDecrypt(
+ messageId = message.id,
+ fileIndex = fileIndex,
+ file = file,
+ envelope = envelope,
+ currentUserId = currentUserId,
+ clientMessageId = clientMessageId,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ fun isCancelled(storageKey: String): Boolean =
+ storageKey in _cancelledKeys.value || ApiClient.isPartialDownloadUserDismissed(storageKey)
+
+ fun isCancelled(
+ messageId: Int,
+ fileIndex: Int,
+ clientMessageId: String? = null,
+ mirrorAsFileAttachment: Boolean = false,
+ ): Boolean {
+ val keys = lookupKeys(messageId, fileIndex, clientMessageId, mirrorAsFileAttachment)
+ return keys.any { isCancelled(it) }
+ }
+
+ fun hasResumablePartial(
+ messageId: Int,
+ fileIndex: Int,
+ clientMessageId: String? = null,
+ mirrorAsFileAttachment: Boolean = false,
+ ): Boolean {
+ val keys = lookupKeys(messageId, fileIndex, clientMessageId, mirrorAsFileAttachment)
+ return keys.any { ApiClient.hasResumablePartialOnDisk(it) }
+ }
+
+ private fun lookupKeys(
+ messageId: Int,
+ fileIndex: Int,
+ clientMessageId: String? = null,
+ mirrorAsFileAttachment: Boolean = false,
+ ): List = if (mirrorAsFileAttachment) {
+ DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
+ } else {
+ DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
}
fun isFailed(
@@ -129,11 +337,33 @@ object AttachmentDownloadNotifier {
clientMessageId: String? = null,
mirrorAsFileAttachment: Boolean = false,
): Boolean {
- val keys = if (mirrorAsFileAttachment) {
- DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
- } else {
- DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
- }
+ val keys = lookupKeys(messageId, fileIndex, clientMessageId, mirrorAsFileAttachment)
return keys.any { it in _failedKeys.value }
}
+
+ private fun mirrorKeysFor(
+ primaryKey: String,
+ messageId: Int,
+ fileIndex: Int,
+ clientMessageId: String?,
+ mirrorAsFileAttachment: Boolean,
+ ): List = when {
+ mirrorAsFileAttachment || primaryKey.startsWith("file_") ->
+ DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
+ else ->
+ DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
+ }.ifEmpty { listOf(primaryKey) }
+
+ private fun applyProgressPercent(keys: List, percent: Int) {
+ val pct = percent.coerceIn(1, 99)
+ val throttle = throttleFor(keys)
+ if (throttle.shouldPublishUi(pct)) {
+ _progressPercentByKey.update { map -> map + keys.associateWith { pct } }
+ }
+ }
+
+ private fun throttleFor(keys: List): DownloadProgressThrottle {
+ val id = keys.firstOrNull() ?: return DownloadProgressThrottle()
+ return progressThrottleByKey.getOrPut(id) { DownloadProgressThrottle() }
+ }
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.kt
new file mode 100644
index 0000000..8bc8ce5
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.kt
@@ -0,0 +1,6 @@
+package ru.fromchat.api
+
+expect object AttachmentFileCopyForeground {
+ fun onCopyStarted(storageKey: String, displayLabel: String? = null)
+ fun onCopyFinished(storageKey: String)
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentTransferBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentTransferBootstrap.kt
new file mode 100644
index 0000000..15bec6c
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentTransferBootstrap.kt
@@ -0,0 +1,59 @@
+package ru.fromchat.api
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import ru.fromchat.api.db.MessageDatabaseProvider
+import ru.fromchat.api.outbox.DmAttachmentOutboxPayload
+import ru.fromchat.api.outbox.OutgoingMessageCoordinator
+import ru.fromchat.api.outbox.scheduleOutboxProcessing
+import ru.fromchat.core.cache.repairInterruptedUploadArtifacts
+import kotlinx.serialization.json.Json
+import ru.fromchat.core.cache.CacheContext
+import ru.fromchat.core.instance.applyCachedSessionInstanceIfAvailable
+import ru.fromchat.core.instance.scheduleSessionInstanceNetworkRefresh
+/**
+ * Cold-start hook for attachment downloads and outbound media uploads.
+ * Call from Android [android.app.Application] and from the iOS app entry (not Activity / Compose lifecycle).
+ */
+object AttachmentTransferBootstrap {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private val json = Json { ignoreUnknownKeys = true }
+
+ fun launchOnApplicationStart() {
+ scope.launch {
+ runCatching { runColdStart() }
+ }
+ }
+
+ suspend fun runColdStart() {
+ AttachmentDownloadNotifier.hydrateFromDisk()
+ if (ApiClient.token.isNullOrEmpty()) return
+ applyCachedSessionInstanceIfAvailable()
+ resumeAttachmentsForActiveInstance()
+ scheduleSessionInstanceNetworkRefresh()
+ }
+
+ private suspend fun resumeAttachmentsForActiveInstance() {
+ val instanceId = CacheContext.activeInstanceId.value.trim()
+ if (instanceId.isEmpty()) return
+ repairPendingAttachmentArtifacts(instanceId)
+ scheduleOutboxProcessing(instanceId)
+ AttachmentDownloadNotifier.resumeInterruptedDownloadsOnAppStart()
+ }
+
+ private suspend fun repairPendingAttachmentArtifacts(instanceId: String) {
+ val rows = MessageDatabaseProvider.database.messageDatabaseQueries
+ .selectPendingOutboxForInstance(instanceId)
+ .executeAsList()
+ for (row in rows) {
+ if (row.kind != OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT) continue
+ val clientMessageId = runCatching {
+ json.decodeFromString(row.payloadJson).clientMessageId.trim()
+ }.getOrNull().orEmpty()
+ if (clientMessageId.isEmpty()) continue
+ repairInterruptedUploadArtifacts(instanceId, clientMessageId)
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/DownloadProgressThrottle.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/DownloadProgressThrottle.kt
new file mode 100644
index 0000000..644e458
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/DownloadProgressThrottle.kt
@@ -0,0 +1,46 @@
+package ru.fromchat.api
+
+import ru.fromchat.ui.chat.AttachmentMediaLog
+
+/**
+ * Coalesces download progress for UI (~display refresh rate) and system notifications (≤1/s).
+ */
+internal class DownloadProgressThrottle(
+ private val uiFrameMs: Long = 16L,
+ private val notificationIntervalMs: Long = 1_000L,
+) {
+ private var lastUiAtMs = 0L
+ private var lastUiPercent = Int.MIN_VALUE
+ private var lastNotifAtMs = 0L
+ private var lastNotifPercent = Int.MIN_VALUE
+
+ fun shouldPublishUi(percent: Int, nowMs: Long = AttachmentMediaLog.nowMs()): Boolean {
+ val pct = percent.coerceIn(0, 100)
+ if (pct == lastUiPercent) return false
+ if (pct <= 1 || pct >= 100 || lastUiAtMs == 0L || nowMs - lastUiAtMs >= uiFrameMs) {
+ lastUiPercent = pct
+ lastUiAtMs = nowMs
+ return true
+ }
+ return false
+ }
+
+ fun shouldPublishNotification(percent: Int, nowMs: Long = AttachmentMediaLog.nowMs()): Boolean {
+ val pct = percent.coerceIn(0, 100)
+ if (pct >= 100 || pct <= 1 || lastNotifAtMs == 0L || nowMs - lastNotifAtMs >= notificationIntervalMs) {
+ if (pct != lastNotifPercent || nowMs - lastNotifAtMs >= notificationIntervalMs) {
+ lastNotifPercent = pct
+ lastNotifAtMs = nowMs
+ return true
+ }
+ }
+ return false
+ }
+
+ fun reset() {
+ lastUiAtMs = 0L
+ lastUiPercent = Int.MIN_VALUE
+ lastNotifAtMs = 0L
+ lastNotifPercent = Int.MIN_VALUE
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.kt
new file mode 100644
index 0000000..2cd4517
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.kt
@@ -0,0 +1,103 @@
+package ru.fromchat.api
+
+import com.pr0gramm3r101.utils.files.PlatformFileSystem
+import ru.fromchat.core.files.FileWriteSink
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.HttpTimeoutConfig
+import io.ktor.client.request.header
+import io.ktor.client.request.prepareGet
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentLength
+import io.ktor.utils.io.ByteReadChannel
+import io.ktor.utils.io.readAvailable
+import kotlinx.coroutines.ensureActive
+import kotlin.coroutines.coroutineContext
+
+private const val DOWNLOAD_CHUNK_BYTES = 256 * 1024
+
+/** Platform HTTP client for large encrypted downloads (must stream without buffering the full body). */
+internal expect fun encryptedDownloadHttpClient(): HttpClient
+
+private val encryptedDownloadHttp: HttpClient by lazy { encryptedDownloadHttpClient() }
+
+/**
+ * Streams an encrypted attachment HTTP response to [outputPath] without buffering the full body in RAM.
+ */
+internal suspend fun streamEncryptedFileToDisk(
+ url: String,
+ outputPath: String,
+ rangeOffset: Long,
+ bearerToken: String?,
+ userAgent: String?,
+ onChunkReceived: (receivedBytes: Long, totalBytes: Long?) -> Unit,
+): Long = encryptedDownloadHttp.prepareGet(url) {
+ bearerToken?.let { header(HttpHeaders.Authorization, "Bearer $it") }
+ userAgent?.let { header(HttpHeaders.UserAgent, it) }
+ if (rangeOffset > 0L) {
+ header(HttpHeaders.Range, "bytes=$rangeOffset-")
+ }
+}.execute { response ->
+ if (response.status.value !in 200..299) {
+ error("HTTP ${response.status.value} for encrypted file download")
+ }
+ if (response.status == HttpStatusCode.OK && rangeOffset > 0L) {
+ PlatformFileSystem.delete(outputPath)
+ }
+
+ val totalBytes = responseTotalBytes(response, rangeOffset)
+ val channel = response.bodyAsChannel()
+ var received = if (response.status == HttpStatusCode.PartialContent) rangeOffset else 0L
+ val appendToPartial = rangeOffset > 0L && response.status == HttpStatusCode.PartialContent
+ streamChannelToFile(
+ channel = channel,
+ outputPath = outputPath,
+ append = appendToPartial,
+ ) { chunkSize ->
+ received += chunkSize
+ onChunkReceived(received, totalBytes)
+ }
+ received
+}
+
+private suspend fun streamChannelToFile(
+ channel: ByteReadChannel,
+ outputPath: String,
+ append: Boolean,
+ onChunk: (Int) -> Unit,
+) {
+ val buffer = ByteArray(DOWNLOAD_CHUNK_BYTES)
+ FileWriteSink(path = outputPath, append = append).use { sink ->
+ while (!channel.isClosedForRead) {
+ coroutineContext.ensureActive()
+ val read = channel.readAvailable(buffer, offset = 0, length = buffer.size)
+ when {
+ read > 0 -> {
+ sink.write(buffer, offset = 0, length = read)
+ onChunk(read)
+ }
+ read < 0 -> break
+ else -> if (!channel.awaitContent()) break
+ }
+ }
+ sink.flush()
+ }
+}
+
+private fun responseTotalBytes(response: HttpResponse, rangeOffset: Long): Long? {
+ val contentRange = response.headers[HttpHeaders.ContentRange]
+ if (contentRange != null) {
+ val total = contentRange.substringAfterLast('/').toLongOrNull()
+ if (total != null && total > 0L) return total
+ }
+ val contentLength: Long? = response.contentLength()
+ return when {
+ response.status == HttpStatusCode.PartialContent && contentLength != null ->
+ rangeOffset + contentLength
+ contentLength != null && contentLength > 0L -> contentLength
+ else -> null
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt
index e8005f7..cc5bcc5 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt
@@ -159,6 +159,8 @@ data class Message(
val uploadJobId: String? = null,
/** For optimistic UI: 0-100 upload progress, null when complete. */
val uploadProgress: Int? = null,
+ /** Set when outbound upload failed; use [UPLOAD_ERROR_FILE_TOO_LARGE] for localized copy. */
+ @kotlinx.serialization.Transient val uploadError: String? = null,
/** For DM file decryption; not serialized over network. */
@kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null,
/** Blurhashes for image files (by index); from decrypted message JSON. */
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt
index 542054f..088692b 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt
@@ -23,6 +23,7 @@ private data class PersistedOptimisticOutboundPayload(
@SerialName("pendingFileUri") val pendingFileUri: String? = null,
@SerialName("pendingFilename") val pendingFilename: String? = null,
@SerialName("uploadJobId") val uploadJobId: String? = null,
+ @SerialName("fileSizes") val fileSizes: List? = null,
)
@Serializable
@@ -62,6 +63,7 @@ fun encodeOptimisticOutboundMessage(message: Message): String {
pendingFileUri = pendingUri,
pendingFilename = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() },
uploadJobId = message.uploadJobId?.trim()?.takeIf { it.isNotEmpty() },
+ fileSizes = message.fileSizes,
),
)
}
@@ -140,6 +142,7 @@ fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent {
pendingFileUri = payload.pendingFileUri?.takeIf { it.isNotBlank() },
pendingFilename = payload.pendingFilename?.takeIf { it.isNotBlank() },
uploadJobId = payload.uploadJobId?.takeIf { it.isNotBlank() },
+ fileSizes = payload.fileSizes,
)
}.getOrElse {
ParsedDmMessageContent(text = plaintext)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt
index ab2c774..7aa1c3d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt
@@ -12,6 +12,7 @@ import ru.fromchat.api.DmConversation
import ru.fromchat.api.Message
import ru.fromchat.api.sortMessagesForChatDisplay
import ru.fromchat.ui.chat.DecryptedImageCache
+import ru.fromchat.ui.chat.DownloadedFileRegistry
import ru.fromchat.ui.chat.dedupeMessagesByClientId
import ru.fromchat.ui.chat.dropSupersededOptimisticMessages
import ru.fromchat.api.ProfileCache
@@ -472,6 +473,8 @@ object MessageCacheStore {
?: msg.pendingFileAspectRatio,
uploadJobId = cid,
uploadProgress = percent,
+ fileSizes = msg.fileSizes
+ ?: payload.fileSizeBytes.takeIf { it > 0L }?.let { listOf(it) },
)
}
}
@@ -538,6 +541,7 @@ object MessageCacheStore {
?: resolveLocalPreviewUri(base),
pendingFilename = parsed.pendingFilename ?: base.pendingFilename,
uploadJobId = parsed.uploadJobId ?: base.uploadJobId,
+ fileSizes = parsed.fileSizes ?: base.fileSizes,
pendingFileAspectRatio = parsed.fileAspectRatios?.firstOrNull()
?: parsed.fileDimensions?.firstOrNull()?.let { (w, h) ->
aspectRatioFromDimensionPair(w, h)
@@ -607,4 +611,69 @@ object MessageCacheStore {
.executeAsOneOrNull() != null
}
}
+
+ data class AttachmentResumeTarget(
+ val message: Message,
+ val fileIndex: Int,
+ )
+
+ suspend fun findMessageForAttachmentStorageKey(storageKey: String): AttachmentResumeTarget? =
+ withContext(Dispatchers.Default) {
+ val key = storageKey.trim()
+ if (key.isEmpty()) return@withContext null
+ val iid = runCatching { instanceId() }.getOrNull() ?: return@withContext null
+ val fileIndex = when {
+ key.startsWith("file_") -> DownloadedFileRegistry.fileIndexFromStorageKey(key)
+ key.startsWith("img_") -> fileIndexFromImageStorageKey(key)
+ else -> null
+ } ?: return@withContext null
+
+ val messageId = when {
+ key.startsWith("file_") -> DownloadedFileRegistry.messageIdFromStorageKey(key)
+ key.startsWith("img_") -> DecryptedImageCache.messageIdFromStorageKey(key)
+ else -> null
+ }
+ if (messageId != null && messageId > 0) {
+ val row = db.messageDatabaseQueries
+ .selectMessageByNumericId(iid, messageId.toLong())
+ .executeAsOneOrNull()
+ val msg = row?.toAppMessage()
+ if (msg != null && !msg.files.isNullOrEmpty()) {
+ return@withContext AttachmentResumeTarget(msg, fileIndex)
+ }
+ }
+
+ val rows = db.messageDatabaseQueries.selectMessagesForInstance(iid).executeAsList()
+ for (row in rows) {
+ val msg = row.toAppMessage()
+ if (msg.files.isNullOrEmpty()) continue
+ val lookupKeys = if (key.startsWith("file_")) {
+ DownloadedFileRegistry.progressLookupKeys(
+ messageId = msg.id,
+ fileIndex = fileIndex,
+ clientMessageId = msg.client_message_id,
+ )
+ } else {
+ DecryptedImageCache.progressLookupKeys(
+ messageId = msg.id,
+ fileIndex = fileIndex,
+ clientMessageId = msg.client_message_id,
+ )
+ }
+ if (key in lookupKeys) {
+ return@withContext AttachmentResumeTarget(msg, fileIndex)
+ }
+ }
+ null
+ }
+
+ private fun fileIndexFromImageStorageKey(storageKey: String): Int? {
+ if (storageKey.startsWith("img_c_")) {
+ return storageKey.substringAfterLast('_').toIntOrNull()
+ }
+ if (storageKey.startsWith("img_")) {
+ return storageKey.substringAfterLast('_').toIntOrNull()
+ }
+ return null
+ }
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/DmAttachmentOutboxHandler.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/DmAttachmentOutboxHandler.kt
index d48ef6e..cfc3654 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/DmAttachmentOutboxHandler.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/DmAttachmentOutboxHandler.kt
@@ -15,19 +15,34 @@ import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.api.db.MessageDatabaseProvider
import ru.fromchat.api.db.conversationIdForDm
import ru.fromchat.core.cache.OutboundFileUnavailableException
+import ru.fromchat.core.cache.UPLOAD_ERROR_FILE_TOO_LARGE
import ru.fromchat.core.cache.clearUploadSecretsOnly
import ru.fromchat.api.optimisticMessageIdForClientMessageId
+import ru.fromchat.core.cache.commitEncryptedUploadBlob
+import ru.fromchat.core.cache.encryptedUploadBlobPartPath
+import ru.fromchat.core.cache.encryptedUploadBlobSizeBytes
+import ru.fromchat.core.cache.isEncryptedBlobReady
+import ru.fromchat.core.cache.isFileTooLargeForUpload
+import ru.fromchat.core.cache.isLikelyUploadMemoryError
import ru.fromchat.core.cache.isOutboundFileUnavailable
-import ru.fromchat.ui.chat.AttachmentMediaLog
-import ru.fromchat.ui.chat.clearOutboundImageCaches
import ru.fromchat.core.cache.loadEncryptedUploadBlob
import ru.fromchat.core.cache.loadUploadTransportCipherJson
+import ru.fromchat.core.cache.queryOutboundUriSizeBytes
+import ru.fromchat.core.cache.readEncryptedUploadBlobRange
import ru.fromchat.core.cache.readOutboundFileBytes
+import ru.fromchat.core.cache.repairInterruptedUploadArtifacts
import ru.fromchat.core.cache.saveEncryptedUploadBlob
-import ru.fromchat.core.cache.saveUploadTransportCipherJson
+import ru.fromchat.core.cache.saveUploadTransportCipherJsonAtomic
+import ru.fromchat.core.cache.shouldStreamEncryptPlaintext
import ru.fromchat.core.cache.stageOutboundFileForUpload
+import ru.fromchat.ui.chat.AttachmentMediaLog
+import ru.fromchat.ui.chat.clearOutboundFileCaches
+import ru.fromchat.ui.chat.clearOutboundImageCaches
+import ru.fromchat.ui.chat.isImageFilename
+import ru.fromchat.ui.chat.seedOutboundFileAsDownloaded
import ru.fromchat.crypto.transport.TransportCiphertext
import ru.fromchat.crypto.transport.TransportCrypto
+import ru.fromchat.crypto.transport.TransportFileEncryptor
import ru.fromchat.db.Outbox
private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024
@@ -85,12 +100,22 @@ object DmAttachmentOutboxHandler {
val serverUploadId = arrayOf(payload.uploadId.trim())
return runCatching {
+ repairInterruptedUploadArtifacts(instanceId, clientMessageId)
ensureStillQueued(instanceId, clientMessageId)
AttachmentUploadNotifier.emit(
AttachmentUploadProgress.Pending(clientMessageId, payload.filename),
messageLabel = payload.plaintext,
)
val stagedPayload = ensureStagedPayload(instanceId, row, payload)
+ if (!isImageFilename(stagedPayload.filename)) {
+ seedOutboundFileAsDownloaded(
+ messageId = optimisticMessageIdForClientMessageId(clientMessageId),
+ fileIndex = 0,
+ localFileUri = stagedPayload.fileUri,
+ displayFilename = stagedPayload.filename,
+ clientMessageId = clientMessageId,
+ )
+ }
ensureStillQueued(instanceId, clientMessageId)
val restoredPercent = uploadPercent(row.bytesUploaded, stagedPayload)
if (restoredPercent > 0) {
@@ -99,55 +124,50 @@ object DmAttachmentOutboxHandler {
emitProgress(clientMessageId, 0, stagedPayload.filename, stagedPayload.plaintext)
}
- val prepared = loadPrepared(instanceId, clientMessageId)
- val encryptedBlob: ByteArray
- val msgCipher: TransportCiphertext
var activePayload = stagedPayload
-
- if (prepared != null) {
- encryptedBlob = prepared.first
- msgCipher = prepared.second
- if (activePayload.encryptedFileSizeBytes <= 0L) {
- activePayload = activePayload.copy(encryptedFileSizeBytes = encryptedBlob.size.toLong())
- }
+ val expectedEncryptedSize = activePayload.encryptedFileSizeBytes.takeIf { it > 0L }
+ val preparedCipher = loadPreparedCipher(
+ instanceId,
+ clientMessageId,
+ expectedEncryptedSize,
+ )
+ var encryptedSize = encryptedUploadBlobSizeBytes(instanceId, clientMessageId)
+ ?.takeIf { it > 0L }
+ ?: expectedEncryptedSize
+ val msgCipher: TransportCiphertext
+ if (preparedCipher != null && encryptedSize != null) {
+ msgCipher = preparedCipher
} else {
- ensureStillQueued(instanceId, clientMessageId)
- val bytes = readOutboundFileBytes(stagedPayload.fileUri)
- OutgoingMessageCoordinator.abortDmServerUploadIfNeeded(stagedPayload.uploadId)
- serverUploadId[0] = ""
- activePayload = stagedPayload.copy(uploadId = "", encryptedFileSizeBytes = 0L)
- persistPayloadProgress(instanceId, row, activePayload, bytesUploaded = 0L)
- ensureStillQueued(instanceId, clientMessageId)
- val transportKey = ApiClient.getTransportPublicKey()
- val (freshCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret(
- plaintext = stagedPayload.plaintext,
- transportPublicKeyB64 = transportKey.publicKeyB64,
+ clearPrepared(instanceId, clientMessageId)
+ val encrypted = encryptAndPersistToDisk(
+ instanceId = instanceId,
+ row = row,
+ stagedPayload = stagedPayload,
+ serverUploadId = serverUploadId,
)
- try {
- val blob = TransportCrypto.encryptFileForTransport(
- fileBytes = bytes,
- transportPublicKeyB64 = transportKey.publicKeyB64,
- ephemeralSecretKey = ephemeralSecret,
- )
- encryptedBlob = blob
- msgCipher = freshCipher
- savePrepared(instanceId, clientMessageId, encryptedBlob, msgCipher)
- activePayload = activePayload.copy(encryptedFileSizeBytes = encryptedBlob.size.toLong())
- persistPayloadProgress(instanceId, row, activePayload, bytesUploaded = 0L)
- } finally {
- ephemeralSecret.fill(0)
- }
+ encryptedSize = encrypted.encryptedSize
+ msgCipher = encrypted.cipher
+ activePayload = encrypted.payload
+ }
+ val blobSize = encryptedSize ?: return@runCatching false
+ if (activePayload.encryptedFileSizeBytes <= 0L) {
+ activePayload = activePayload.copy(encryptedFileSizeBytes = blobSize)
+ }
+ if (!isEncryptedBlobReady(instanceId, clientMessageId, blobSize)) {
+ return@runCatching false
}
ensureStillQueued(instanceId, clientMessageId)
- if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) {
+ if (blobSize <= INLINE_UPLOAD_THRESHOLD_BYTES) {
+ val encryptedBlob = loadEncryptedUploadBlob(instanceId, clientMessageId)
+ ?: return@runCatching false
sendInline(activePayload, encryptedBlob, msgCipher)
} else {
sendResumable(
instanceId = instanceId,
row = row,
payload = activePayload,
- encryptedBlob = encryptedBlob,
+ encryptedSize = blobSize,
msgCipher = msgCipher,
serverUploadId = serverUploadId,
)
@@ -186,18 +206,22 @@ object DmAttachmentOutboxHandler {
messageLabel = payload.plaintext,
)
runCatching {
- clearOutboundImageCaches(
- clientMessageId,
- optimisticMessageIdForClientMessageId(clientMessageId),
- )
+ val optimisticId = optimisticMessageIdForClientMessageId(clientMessageId)
+ clearOutboundImageCaches(clientMessageId, optimisticId)
+ clearOutboundFileCaches(clientMessageId, optimisticId)
OutgoingMessageCoordinator.cancelOutboundMessage(clientMessageId, row.conversationId)
}
return true
}
+ val failureKey = when {
+ error.message == UPLOAD_ERROR_FILE_TOO_LARGE -> UPLOAD_ERROR_FILE_TOO_LARGE
+ isLikelyUploadMemoryError(error) -> UPLOAD_ERROR_FILE_TOO_LARGE
+ else -> error.message ?: "Upload failed"
+ }
AttachmentUploadNotifier.emit(
AttachmentUploadProgress.Failed(
jobId = clientMessageId,
- error = error.message ?: "Upload failed",
+ error = failureKey,
),
messageLabel = payload.plaintext,
)
@@ -205,6 +229,101 @@ object DmAttachmentOutboxHandler {
}
}
+ private data class EncryptedUploadPrepared(
+ val encryptedSize: Long,
+ val cipher: TransportCiphertext,
+ val payload: DmAttachmentOutboxPayload,
+ )
+
+ private suspend fun encryptAndPersistToDisk(
+ instanceId: String,
+ row: Outbox,
+ stagedPayload: DmAttachmentOutboxPayload,
+ serverUploadId: Array,
+ ): EncryptedUploadPrepared {
+ repairInterruptedUploadArtifacts(instanceId, stagedPayload.clientMessageId)
+ ensureStillQueued(instanceId, stagedPayload.clientMessageId)
+ if (isFileTooLargeForUpload(stagedPayload.fileSizeBytes)) {
+ throw IllegalStateException(UPLOAD_ERROR_FILE_TOO_LARGE)
+ }
+ OutgoingMessageCoordinator.abortDmServerUploadIfNeeded(stagedPayload.uploadId)
+ serverUploadId[0] = ""
+ var activePayload = stagedPayload.copy(uploadId = "", encryptedFileSizeBytes = 0L)
+ persistPayloadProgress(instanceId, row, activePayload, bytesUploaded = 0L)
+ ensureStillQueued(instanceId, stagedPayload.clientMessageId)
+ val transportKey = ApiClient.getTransportPublicKey()
+ val (freshCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret(
+ plaintext = stagedPayload.plaintext,
+ transportPublicKeyB64 = transportKey.publicKeyB64,
+ )
+ try {
+ val cipherJson = json.encodeToString(
+ StoredTransportCipher(
+ clientPublicKeyB64 = freshCipher.clientPublicKeyB64,
+ nonceB64 = freshCipher.nonceB64,
+ ciphertextB64 = freshCipher.ciphertextB64,
+ ),
+ )
+ val encryptedSize = if (shouldStreamEncryptPlaintext(stagedPayload.fileSizeBytes)) {
+ val destPath = encryptedUploadBlobPartPath(instanceId, stagedPayload.clientMessageId)
+ val size = TransportFileEncryptor.encryptPlaintextFileToTransportBlob(
+ sourceUri = stagedPayload.fileUri,
+ destinationPath = destPath,
+ transportPublicKeyB64 = transportKey.publicKeyB64,
+ ephemeralSecretKey = ephemeralSecret,
+ plaintextSizeBytes = stagedPayload.fileSizeBytes,
+ onPlaintextProgress = { read, total ->
+ if (total > 0L) {
+ val percent = ((read.toDouble() / total.toDouble()) * 50.0).toInt().coerceIn(0, 50)
+ emitProgress(
+ stagedPayload.clientMessageId,
+ percent,
+ stagedPayload.filename,
+ stagedPayload.plaintext,
+ )
+ }
+ },
+ )
+ saveUploadTransportCipherJsonAtomic(instanceId, stagedPayload.clientMessageId, cipherJson)
+ commitEncryptedUploadBlob(instanceId, stagedPayload.clientMessageId, size)
+ size
+ } else {
+ val bytes = try {
+ readOutboundFileBytes(stagedPayload.fileUri)
+ } catch (error: Throwable) {
+ if (isLikelyUploadMemoryError(error)) {
+ throw IllegalStateException(UPLOAD_ERROR_FILE_TOO_LARGE)
+ }
+ throw error
+ }
+ val blob = try {
+ TransportCrypto.encryptFileForTransport(
+ fileBytes = bytes,
+ transportPublicKeyB64 = transportKey.publicKeyB64,
+ ephemeralSecretKey = ephemeralSecret,
+ )
+ } catch (error: Throwable) {
+ if (isLikelyUploadMemoryError(error)) {
+ throw IllegalStateException(UPLOAD_ERROR_FILE_TOO_LARGE)
+ }
+ throw error
+ }
+ saveEncryptedUploadBlob(instanceId, stagedPayload.clientMessageId, blob)
+ saveUploadTransportCipherJsonAtomic(instanceId, stagedPayload.clientMessageId, cipherJson)
+ commitEncryptedUploadBlob(instanceId, stagedPayload.clientMessageId, blob.size.toLong())
+ blob.size.toLong()
+ }
+ if (encryptedSize <= 0L) {
+ throw IllegalStateException(UPLOAD_ERROR_FILE_TOO_LARGE)
+ }
+ activePayload = activePayload.copy(encryptedFileSizeBytes = encryptedSize)
+ persistPayloadProgress(instanceId, row, activePayload, bytesUploaded = 0L)
+ return EncryptedUploadPrepared(encryptedSize, freshCipher, activePayload)
+ } finally {
+ ephemeralSecret.fill(0)
+ }
+ }
+
private suspend fun sendInline(
payload: DmAttachmentOutboxPayload,
encryptedBlob: ByteArray,
@@ -229,55 +348,93 @@ object DmAttachmentOutboxHandler {
instanceId: String,
row: Outbox,
payload: DmAttachmentOutboxPayload,
- encryptedBlob: ByteArray,
+ encryptedSize: Long,
msgCipher: TransportCiphertext,
serverUploadId: Array,
) {
- var uploadId = payload.uploadId.trim().ifBlank { serverUploadId[0] }
+ if (encryptedSize <= 0L) return
+ if (!isEncryptedBlobReady(instanceId, payload.clientMessageId, encryptedSize)) {
+ throw OutboundFileUnavailableException("Encrypted upload blob not ready")
+ }
+ var activePayload = payload
+ var bytesUploaded = row.bytesUploaded.coerceAtLeast(0L)
+ if (bytesUploaded > encryptedSize) {
+ bytesUploaded = 0L
+ }
+ if (payload.encryptedFileSizeBytes > 0L && payload.encryptedFileSizeBytes != encryptedSize) {
+ bytesUploaded = 0L
+ val staleUploadId = activePayload.uploadId.trim()
+ if (staleUploadId.isNotEmpty()) {
+ OutgoingMessageCoordinator.abortDmServerUploadIfNeeded(staleUploadId)
+ }
+ activePayload = activePayload.copy(uploadId = "")
+ }
+ var uploadId = activePayload.uploadId.trim().ifBlank { serverUploadId[0] }
try {
if (uploadId.isEmpty()) {
val init = ApiClient.initDmUpload(
filename = payload.filename,
- totalSize = encryptedBlob.size.toLong(),
+ totalSize = encryptedSize,
recipientId = payload.recipientId,
chunkSize = DEFAULT_CHUNK_SIZE,
)
uploadId = init.uploadId
serverUploadId[0] = uploadId
- persistPayloadProgress(instanceId, row, payload.copy(uploadId = uploadId), row.bytesUploaded)
+ persistPayloadProgress(instanceId, row, activePayload.copy(uploadId = uploadId), bytesUploaded)
} else {
serverUploadId[0] = uploadId
}
- var offset = row.bytesUploaded.toInt().coerceAtLeast(0)
- val serverOffset = ApiClient.getDmUploadStatus(uploadId).offset.toInt().coerceAtLeast(0)
- offset = maxOf(offset, serverOffset)
- while (offset < encryptedBlob.size) {
- ensureStillQueued(instanceId, payload.clientMessageId)
- val nextOffset = minOf(offset + DEFAULT_CHUNK_SIZE, encryptedBlob.size)
- val chunk = encryptedBlob.copyOfRange(offset, nextOffset)
+ val serverStatus = ApiClient.getDmUploadStatus(uploadId)
+ val serverOffset = serverStatus.offset.coerceAtLeast(0L)
+ if (serverStatus.totalSize > 0L && serverStatus.totalSize != encryptedSize) {
+ OutgoingMessageCoordinator.abortDmServerUploadIfNeeded(uploadId)
+ uploadId = ""
+ serverUploadId[0] = ""
+ bytesUploaded = 0L
+ val init = ApiClient.initDmUpload(
+ filename = activePayload.filename,
+ totalSize = encryptedSize,
+ recipientId = activePayload.recipientId,
+ chunkSize = DEFAULT_CHUNK_SIZE,
+ )
+ uploadId = init.uploadId
+ serverUploadId[0] = uploadId
+ activePayload = activePayload.copy(uploadId = uploadId)
+ persistPayloadProgress(instanceId, row, activePayload, bytesUploaded)
+ }
+ var offset = maxOf(bytesUploaded, serverOffset)
+ while (offset < encryptedSize) {
+ ensureStillQueued(instanceId, activePayload.clientMessageId)
+ val chunkLen = minOf(DEFAULT_CHUNK_SIZE.toLong(), encryptedSize - offset).toInt()
+ val chunk = readEncryptedUploadBlobRange(
+ instanceId = instanceId,
+ clientMessageId = activePayload.clientMessageId,
+ offset = offset,
+ length = chunkLen,
+ )
ApiClient.uploadDmChunk(
uploadId = uploadId,
- offset = offset.toLong(),
+ offset = offset,
dataB64 = Base64.encode(chunk),
)
- offset = nextOffset
- val percent = ((offset.toDouble() / encryptedBlob.size.toDouble()) * 100.0).toInt()
- emitProgress(payload.clientMessageId, percent, payload.filename, payload.plaintext)
+ offset += chunk.size.toLong()
+ val percent = ((offset.toDouble() / encryptedSize.toDouble()) * 100.0).toInt()
+ emitProgress(activePayload.clientMessageId, percent, activePayload.filename, activePayload.plaintext)
persistPayloadProgress(
instanceId,
row,
- payload.copy(uploadId = uploadId),
- offset.toLong(),
+ activePayload.copy(uploadId = uploadId),
+ offset,
)
}
val completed: DmUploadCompleteResponse = ApiClient.completeDmUpload(uploadId)
ApiClient.sendDm(
- recipientId = payload.recipientId,
- plaintext = payload.plaintext,
- clientMessageId = payload.clientMessageId,
- replyToId = payload.replyToId,
+ recipientId = activePayload.recipientId,
+ plaintext = activePayload.plaintext,
+ clientMessageId = activePayload.clientMessageId,
+ replyToId = activePayload.replyToId,
uploadedFileIds = listOf(completed.fileId),
preparedTransport = msgCipher,
)
@@ -294,14 +451,22 @@ object DmAttachmentOutboxHandler {
row: Outbox,
payload: DmAttachmentOutboxPayload,
): DmAttachmentOutboxPayload {
- val staged = stageOutboundFileForUpload(instanceId, payload.clientMessageId, payload.fileUri)
+ val expectedSize = payload.fileSizeBytes.takeIf { it > 0L }
+ ?: queryOutboundUriSizeBytes(payload.fileUri)
+ ?: 0L
+ val staged = stageOutboundFileForUpload(
+ instanceId = instanceId,
+ clientMessageId = payload.clientMessageId,
+ sourceUri = payload.fileUri,
+ expectedSizeBytes = expectedSize,
+ )
if (staged.sizeBytes <= 0L) {
throw OutboundFileUnavailableException("Attachment file is empty or unavailable")
}
- if (staged.uri == payload.fileUri && staged.sizeBytes == payload.fileSizeBytes) {
- return payload
- }
val updated = payload.copy(fileUri = staged.uri, fileSizeBytes = staged.sizeBytes)
+ if (updated.fileUri == payload.fileUri && updated.fileSizeBytes == payload.fileSizeBytes) {
+ return updated
+ }
persistPayloadProgress(instanceId, row, updated, row.bytesUploaded)
return updated
}
@@ -328,20 +493,20 @@ object DmAttachmentOutboxHandler {
}
}
- private suspend fun loadPrepared(
+ private suspend fun loadPreparedCipher(
instanceId: String,
clientMessageId: String,
- ): Pair? {
- val blob = loadEncryptedUploadBlob(instanceId, clientMessageId) ?: return null
+ expectedEncryptedSizeBytes: Long?,
+ ): TransportCiphertext? {
+ if (!isEncryptedBlobReady(instanceId, clientMessageId, expectedEncryptedSizeBytes)) return null
val raw = loadUploadTransportCipherJson(instanceId, clientMessageId) ?: return null
return runCatching {
val stored = json.decodeFromString(raw)
- val cipher = TransportCiphertext(
+ TransportCiphertext(
clientPublicKeyB64 = stored.clientPublicKeyB64,
nonceB64 = stored.nonceB64,
ciphertextB64 = stored.ciphertextB64,
)
- blob to cipher
}.getOrNull()
}
@@ -351,13 +516,14 @@ object DmAttachmentOutboxHandler {
blob: ByteArray,
cipher: TransportCiphertext,
) {
- saveEncryptedUploadBlob(instanceId, clientMessageId, blob)
val stored = StoredTransportCipher(
clientPublicKeyB64 = cipher.clientPublicKeyB64,
nonceB64 = cipher.nonceB64,
ciphertextB64 = cipher.ciphertextB64,
)
- saveUploadTransportCipherJson(instanceId, clientMessageId, json.encodeToString(stored))
+ saveEncryptedUploadBlob(instanceId, clientMessageId, blob)
+ saveUploadTransportCipherJsonAtomic(instanceId, clientMessageId, json.encodeToString(stored))
+ commitEncryptedUploadBlob(instanceId, clientMessageId, blob.size.toLong())
}
private suspend fun clearPrepared(instanceId: String, clientMessageId: String) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutgoingMessageCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutgoingMessageCoordinator.kt
index a8a9d21..2d6b94d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutgoingMessageCoordinator.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutgoingMessageCoordinator.kt
@@ -113,6 +113,7 @@ object OutgoingMessageCoordinator {
filename: String,
optimisticMessage: Message,
aspectRatio: Float? = null,
+ fileSizeBytes: Long = 0L,
) {
val instanceId = CacheContext.requireActiveInstanceId()
val conversationId = conversationIdForDm(recipientId)
@@ -134,7 +135,7 @@ object OutgoingMessageCoordinator {
replyToId = replyToId,
fileUri = fileUri,
filename = filename,
- fileSizeBytes = 0L,
+ fileSizeBytes = fileSizeBytes.coerceAtLeast(0L),
aspectRatio = aspectRatio?.takeIf { it > 0f },
),
)
@@ -178,6 +179,16 @@ object OutgoingMessageCoordinator {
}
}
+ /** Re-queues a failed attachment upload (outbox row must still exist). */
+ fun retryDmAttachmentUpload(clientMessageId: String) {
+ val cid = clientMessageId.trim()
+ if (cid.isEmpty()) return
+ val instanceId = CacheContext.activeInstanceId.value.trim()
+ if (instanceId.isEmpty()) return
+ AttachmentMediaLog.upload("retry_requested", "job" to cid)
+ kickOutboxDrain(instanceId)
+ }
+
/** Drops a queued outbound row, local message, and any upload artifacts. */
suspend fun cancelOutboundMessage(clientMessageId: String, conversationId: String) {
val cid = clientMessageId.trim()
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.kt
new file mode 100644
index 0000000..14ac7d3
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.kt
@@ -0,0 +1,32 @@
+package ru.fromchat.core
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import ru.fromchat.api.ApiClient
+
+/**
+ * Network work that must not block cold start or the first frame.
+ * Call once after auth UI is routable (logged-in shell or login).
+ */
+object DeferredStartupNetwork {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private var scheduled = false
+
+ fun scheduleAfterUiVisible() {
+ if (scheduled) return
+ scheduled = true
+ scope.launch {
+ if (ApiClient.token.isNullOrEmpty()) return@launch
+ runCatching {
+ val profile = ApiClient.getOwnProfile()
+ ApiClient.syncSuspensionStateFromProfile(profile)
+ }
+ runCatching { syncPushTokenAfterStartup() }
+ }
+ }
+}
+
+/** Platform push token registration (FCM on Android). No-op where unsupported. */
+expect suspend fun syncPushTokenAfterStartup()
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.kt
new file mode 100644
index 0000000..f6955e0
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.kt
@@ -0,0 +1,23 @@
+package ru.fromchat.core.cache
+
+/** Error key stored on [ru.fromchat.api.Message.uploadError] for localized UI. */
+const val UPLOAD_ERROR_FILE_TOO_LARGE = "file_too_large"
+
+/** Maximum plaintext attachment size (matches file_storage MAX_UPLOAD_SIZE). */
+const val MAX_OUTBOUND_ATTACHMENT_BYTES: Long = 5L * 1024L * 1024L * 1024L
+
+/** Legacy in-memory encrypt threshold; larger files use [TransportFileEncryptor] streaming. */
+expect fun maxInMemoryEncryptPlaintextBytes(): Long
+
+internal fun isFileTooLargeForUpload(fileSizeBytes: Long): Boolean =
+ fileSizeBytes > MAX_OUTBOUND_ATTACHMENT_BYTES
+
+internal fun shouldStreamEncryptPlaintext(fileSizeBytes: Long): Boolean =
+ fileSizeBytes > maxInMemoryEncryptPlaintextBytes()
+
+internal fun isLikelyUploadMemoryError(error: Throwable?): Boolean {
+ if (error == null) return false
+ if (error::class.simpleName == "OutOfMemoryError") return true
+ val msg = error.message?.lowercase().orEmpty()
+ return msg.contains("outofmemory") || msg.contains("failed to allocate")
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.kt
index 469cd84..7e15e91 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.kt
@@ -5,26 +5,87 @@ data class StagedOutboundFile(
val sizeBytes: Long,
)
+/** Best-effort size for a picker URI without loading file contents. */
+expect suspend fun queryOutboundUriSizeBytes(fileUri: String): Long?
+
/**
* Copies the picked attachment into instance-scoped cache so uploads survive process death.
+ * Uses atomic write (`.part` → rename) and a `.source.ok` marker.
*/
expect suspend fun stageOutboundFileForUpload(
instanceId: String,
clientMessageId: String,
sourceUri: String,
+ expectedSizeBytes: Long = 0L,
): StagedOutboundFile
/** Reads the picked attachment from a platform URI string. */
expect suspend fun readOutboundFileBytes(fileUri: String): ByteArray
+/** Copies a staged/picked file into [destinationPath] without loading the whole file into RAM. */
+expect suspend fun copyOutboundFileToPath(sourceUri: String, destinationPath: String)
+
+/** Absolute path for the committed encrypted upload blob (`*.enc`). */
+expect fun encryptedUploadBlobPath(instanceId: String, clientMessageId: String): String
+
+/** Absolute path for in-progress encrypted blob (`*.enc.part`). */
+expect fun encryptedUploadBlobPartPath(instanceId: String, clientMessageId: String): String
+
+/** Opens a streaming reader for a staged outbound file URI. Caller must close. */
+expect suspend fun openOutboundFileInputStream(fileUri: String): OutboundFileInputStream?
+
+interface OutboundFileInputStream {
+ suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int
+ suspend fun close()
+}
+
expect suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray)
expect suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray?
+/** Size of committed `.enc` only (requires `.enc.ok` marker). */
+expect suspend fun encryptedUploadBlobSizeBytes(instanceId: String, clientMessageId: String): Long?
+
+expect suspend fun readEncryptedUploadBlobRange(
+ instanceId: String,
+ clientMessageId: String,
+ offset: Long,
+ length: Int,
+): ByteArray
+
expect suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String)
+/** Atomic write: `.cipher.json.part` then rename. */
+expect suspend fun saveUploadTransportCipherJsonAtomic(
+ instanceId: String,
+ clientMessageId: String,
+ json: String,
+)
+
expect suspend fun loadUploadTransportCipherJson(instanceId: String, clientMessageId: String): String?
+expect suspend fun isStagedSourceReady(
+ instanceId: String,
+ clientMessageId: String,
+ expectedSizeBytes: Long,
+): Boolean
+
+expect suspend fun isEncryptedBlobReady(
+ instanceId: String,
+ clientMessageId: String,
+ expectedEncryptedSizeBytes: Long?,
+): Boolean
+
+/** After streaming encrypt to `.enc.part`, rename and write `.enc.ok`. */
+expect suspend fun commitEncryptedUploadBlob(
+ instanceId: String,
+ clientMessageId: String,
+ encryptedSizeBytes: Long,
+)
+
+/** Drop stale partial files and uncommitted blobs after process death. */
+expect suspend fun repairInterruptedUploadArtifacts(instanceId: String, clientMessageId: String)
+
expect suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String)
/** Drops upload secrets and staging copy; keeps [DecryptedImageCache] files intact. */
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/UploadArtifactMarkers.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/UploadArtifactMarkers.kt
new file mode 100644
index 0000000..3f4e990
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/UploadArtifactMarkers.kt
@@ -0,0 +1,25 @@
+package ru.fromchat.core.cache
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+@Serializable
+internal data class UploadArtifactOkMarker(
+ val actualBytes: Long,
+ val expectedBytes: Long = 0L,
+)
+
+private val markerJson = Json { ignoreUnknownKeys = true }
+
+internal fun encodeUploadArtifactOkMarker(actualBytes: Long, expectedBytes: Long = 0L): String =
+ markerJson.encodeToString(UploadArtifactOkMarker(actualBytes, expectedBytes))
+
+internal fun decodeUploadArtifactOkMarker(raw: String): UploadArtifactOkMarker? =
+ runCatching { markerJson.decodeFromString(raw.trim()) }.getOrNull()
+
+internal fun UploadArtifactOkMarker.isValidOnDisk(diskBytes: Long, expectedBytes: Long): Boolean {
+ if (actualBytes != diskBytes) return false
+ if (expectedBytes > 0L && this.expectedBytes > 0L && this.expectedBytes != expectedBytes) return false
+ if (expectedBytes > 0L && actualBytes != expectedBytes) return false
+ return actualBytes > 0L
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/files/FileWriteSink.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/files/FileWriteSink.kt
new file mode 100644
index 0000000..c6bf42e
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/files/FileWriteSink.kt
@@ -0,0 +1,10 @@
+package ru.fromchat.core.files
+
+/**
+ * Buffered file writer for streaming HTTP downloads (single open, chunked writes).
+ */
+internal expect class FileWriteSink(path: String, append: Boolean) : AutoCloseable {
+ fun write(buffer: ByteArray, offset: Int, length: Int)
+ fun flush()
+ override fun close()
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/SessionInstanceBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/SessionInstanceBootstrap.kt
index 4827127..e2f629d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/SessionInstanceBootstrap.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/SessionInstanceBootstrap.kt
@@ -1,11 +1,17 @@
package ru.fromchat.core.instance
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import ru.fromchat.api.ApiClient
+import ru.fromchat.api.AttachmentDownloadNotifier
import ru.fromchat.api.db.InstanceRegistryStore
import ru.fromchat.core.Settings
import ru.fromchat.api.outbox.scheduleOutboxProcessing
import ru.fromchat.core.cache.CacheContext
-import ru.fromchat.core.config.Config
sealed interface SessionBootstrapResult {
data object Ready : SessionBootstrapResult
@@ -13,17 +19,109 @@ sealed interface SessionBootstrapResult {
data object LogoutRequired : SessionBootstrapResult
}
+private val bootstrapScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+private val refreshMutex = Mutex()
+
+private val attachmentResumeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+private fun scheduleAttachmentResumeAfterSession() {
+ attachmentResumeScope.launch {
+ runCatching { AttachmentDownloadNotifier.hydrateFromDisk() }
+ runCatching { AttachmentDownloadNotifier.resumeInterruptedDownloadsOnAppStart() }
+ }
+}
+
+private fun activateInstance(instanceId: String) {
+ CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
+ scheduleOutboxProcessing(instanceId)
+ scheduleAttachmentResumeAfterSession()
+}
+
/**
- * Ensures [CacheContext] has an active instance for the current server config when a session exists.
+ * Applies the last known instance id for the current server config (local DB only, no network).
*/
-suspend fun bootstrapSessionInstance(hasToken: Boolean): SessionBootstrapResult {
+suspend fun applyCachedSessionInstanceIfAvailable(): Boolean {
+ if (ApiClient.token.isNullOrEmpty()) return false
+ val config = Settings.serverConfig
+ val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty()
+ if (cached.isEmpty() || !isValidInstanceUuid(cached)) return false
+ activateInstance(cached)
+ return true
+}
+
+/**
+ * Fetches `/instance_id` in the background. Safe to call multiple times; coalesces to one in-flight job.
+ */
+fun scheduleSessionInstanceNetworkRefresh(onLogoutRequired: () -> Unit = {}) {
+ if (ApiClient.token.isNullOrEmpty()) return
+ bootstrapScope.launch {
+ refreshMutex.withLock {
+ runCatching {
+ refreshSessionInstanceFromNetwork(onLogoutRequired)
+ }
+ }
+ }
+}
+
+private suspend fun refreshSessionInstanceFromNetwork(onLogoutRequired: () -> Unit) {
+ val config = Settings.serverConfig
+ val apiBase = apiBaseUrlFor(config)
+ when (
+ val resolve = resolveInstanceId(
+ config = config,
+ apiBaseUrl = apiBase,
+ forceNetwork = true,
+ )
+ ) {
+ is InstanceIdResolveResult.Cached,
+ is InstanceIdResolveResult.Fetched,
+ is InstanceIdResolveResult.InstanceIdChanged,
+ -> {
+ val id = when (resolve) {
+ is InstanceIdResolveResult.Cached -> resolve.instanceId
+ is InstanceIdResolveResult.Fetched -> resolve.instanceId
+ is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
+ }
+ activateInstance(id)
+ }
+ InstanceIdResolveResult.Timeout,
+ InstanceIdResolveResult.Unreachable,
+ -> {
+ if (!applyCachedSessionInstanceIfAvailable()) {
+ // No cache and no network — instance will be set when connectivity returns.
+ }
+ }
+ InstanceIdResolveResult.Unsupported -> onLogoutRequired()
+ }
+}
+
+/**
+ * Fast startup path: use cached instance immediately, refresh from server in the background.
+ */
+suspend fun bootstrapSessionOnStartup(
+ hasToken: Boolean,
+ onLogoutRequired: () -> Unit = {},
+): SessionBootstrapResult {
+ if (!hasToken) return SessionBootstrapResult.Ready
+ val hadCache = applyCachedSessionInstanceIfAvailable()
+ scheduleSessionInstanceNetworkRefresh(onLogoutRequired)
+ return if (hadCache) SessionBootstrapResult.Ready else SessionBootstrapResult.OfflineCached
+}
+
+/**
+ * Blocking bootstrap (login, server setup probe follow-up). Prefer [bootstrapSessionOnStartup] for cold start.
+ */
+suspend fun bootstrapSessionInstance(
+ hasToken: Boolean,
+ forceNetwork: Boolean = true,
+): SessionBootstrapResult {
if (!hasToken) return SessionBootstrapResult.Ready
val config = Settings.serverConfig
val apiBase = apiBaseUrlFor(config)
val resolve = resolveInstanceId(
config = config,
apiBaseUrl = apiBase,
- forceNetwork = true,
+ forceNetwork = forceNetwork,
)
return when (resolve) {
is InstanceIdResolveResult.Cached,
@@ -35,8 +133,7 @@ suspend fun bootstrapSessionInstance(hasToken: Boolean): SessionBootstrapResult
is InstanceIdResolveResult.Fetched -> resolve.instanceId
is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
}
- CacheContext.setActiveInstance(id, ApiClient.user?.id)
- scheduleOutboxProcessing(id)
+ activateInstance(id)
SessionBootstrapResult.Ready
}
InstanceIdResolveResult.Timeout,
@@ -44,8 +141,7 @@ suspend fun bootstrapSessionInstance(hasToken: Boolean): SessionBootstrapResult
-> {
val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty()
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
- CacheContext.setActiveInstance(cached, ApiClient.user?.id)
- scheduleOutboxProcessing(cached)
+ activateInstance(cached)
SessionBootstrapResult.OfflineCached
} else {
SessionBootstrapResult.OfflineCached
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt
index 963a8b5..7669053 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt
@@ -52,15 +52,16 @@ suspend fun decryptEnvelope(envelope: DmEnvelope, currentUserId: Int?): String {
}
/**
- * Decrypt a DM file attachment. Fetches encrypted bytes, unwraps file MEK, decrypts.
+ * Decrypt a DM file attachment to [outputPath]: streams download + decrypt without holding the full blob in RAM.
*/
-suspend fun decryptFile(
+suspend fun decryptFileToPath(
file: ru.fromchat.api.DmFile,
envelope: DmEnvelope,
currentUserId: Int?,
+ outputPath: String,
downloadResumeKey: String? = null,
onDownloadProgress: ((Int) -> Unit)? = null,
-): ByteArray {
+): Long {
val wrappedMekB64 = file.wrappedMekB64
?: envelope.files?.find { it.path == file.path }?.wrappedMekB64
?: envelope.wrappedMekB64
@@ -71,17 +72,28 @@ suspend fun decryptFile(
val mek = unwrapMek(wrappedMekB64, envelope, currentUserId)
ru.fromchat.core.Logger.d("DmCrypto", "fetchEncryptedFile path=${file.path}")
- val encryptedBytes = if (downloadResumeKey != null) {
- ru.fromchat.api.ApiClient.fetchEncryptedFileResumable(
- path = file.path,
- resumeKey = downloadResumeKey,
- onProgress = onDownloadProgress,
- )
- } else {
- ru.fromchat.api.ApiClient.fetchEncryptedFile(file.path)
- }
- if (encryptedBytes.isEmpty()) {
+ val encryptedOnDisk = ru.fromchat.api.ApiClient.fetchEncryptedFileResumable(
+ path = file.path,
+ resumeKey = downloadResumeKey,
+ onProgress = onDownloadProgress,
+ )
+ if (encryptedOnDisk.sizeBytes <= 0L) {
throw IllegalArgumentException("Encrypted file is empty: ${file.path}")
}
- return DmCrypto.decryptAesGcm(nonceB64, encryptedBytes, mek)
+ return try {
+ val decryptedSize = DmCrypto.decryptAesGcmFileToPath(
+ ivB64 = nonceB64,
+ encryptedFilePath = encryptedOnDisk.path,
+ mek = mek,
+ outputPath = outputPath,
+ )
+ if (decryptedSize <= 0L) {
+ throw IllegalArgumentException("Decrypted file is empty: ${file.path}")
+ }
+ decryptedSize
+ } finally {
+ if (downloadResumeKey == null) {
+ com.pr0gramm3r101.utils.files.PlatformFileSystem.delete(encryptedOnDisk.path)
+ }
+ }
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/AesGcmStreamingFileDecrypt.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/AesGcmStreamingFileDecrypt.kt
new file mode 100644
index 0000000..66b05a1
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/AesGcmStreamingFileDecrypt.kt
@@ -0,0 +1,43 @@
+package ru.fromchat.crypto.dm
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import ru.fromchat.crypto.DmCiphertextCorruptedException
+
+/**
+ * Streams a MEK-encrypted attachment (ciphertext || tag on disk; IV passed separately) to [outputPath].
+ */
+internal suspend fun aesGcmDecryptMekFileToPath(
+ iv: ByteArray,
+ encryptedPath: String,
+ key: ByteArray,
+ outputPath: String,
+): Long = withContext(Dispatchers.Default) {
+ try {
+ platformAesGcmStreamDecryptMekFile(
+ iv = iv,
+ encryptedPath = encryptedPath,
+ key = key,
+ outputPath = outputPath,
+ )
+ } catch (e: Throwable) {
+ throw if (e is DmCiphertextCorruptedException) {
+ e
+ } else {
+ DmCiphertextCorruptedException(cause = e)
+ }
+ }
+}
+
+internal expect suspend fun platformAesGcmStreamDecryptMekFile(
+ iv: ByteArray,
+ encryptedPath: String,
+ key: ByteArray,
+ outputPath: String,
+): Long
+
+internal fun Throwable.decryptFailureMessage(): String =
+ message?.takeIf { it.isNotBlank() }
+ ?: cause?.message?.takeIf { it.isNotBlank() }
+ ?: this::class.simpleName
+ ?: "download_failed"
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.kt
index e628254..2de3785 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.kt
@@ -31,4 +31,12 @@ expect object DmCrypto {
/** AES-GCM decrypt downloaded file bytes (ciphertext + tag; IV from [ivB64]). */
suspend fun decryptAesGcm(ivB64: String, ciphertext: ByteArray, mek: ByteArray): ByteArray
+
+ /** AES-GCM decrypt from an on-disk ciphertext file into [outputPath] without loading the whole blob. */
+ suspend fun decryptAesGcmFileToPath(
+ ivB64: String,
+ encryptedFilePath: String,
+ mek: ByteArray,
+ outputPath: String,
+ ): Long
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmFileOps.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmFileOps.kt
new file mode 100644
index 0000000..8b4c8f4
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/dm/DmFileOps.kt
@@ -0,0 +1,18 @@
+package ru.fromchat.crypto.dm
+
+/**
+ * DM attachment file decrypt (streaming; shared across Android and iOS).
+ */
+internal object DmFileOps {
+ suspend fun aesGcmDecryptFileToPath(
+ iv: ByteArray,
+ encryptedPath: String,
+ key: ByteArray,
+ outputPath: String,
+ ): Long = aesGcmDecryptMekFileToPath(
+ iv = iv,
+ encryptedPath = encryptedPath,
+ key = key,
+ outputPath = outputPath,
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.kt
new file mode 100644
index 0000000..d1f8790
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.kt
@@ -0,0 +1,69 @@
+package ru.fromchat.crypto.transport
+
+import ru.fromchat.core.cache.openOutboundFileInputStream
+
+/**
+ * Encrypts a plaintext attachment file to a transport blob on disk without loading the full file into RAM.
+ * Uses chunked AES-256-GCM ([TransportStreamFormat]).
+ */
+expect object TransportFileEncryptor {
+ suspend fun encryptPlaintextFileToTransportBlob(
+ sourceUri: String,
+ destinationPath: String,
+ transportPublicKeyB64: String,
+ ephemeralSecretKey: ByteArray,
+ plaintextSizeBytes: Long,
+ onPlaintextProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)? = null,
+ ): Long
+}
+
+internal fun buildAesTransportFrame(iv: ByteArray, ciphertext: ByteArray): ByteArray {
+ val frameLen = TransportStreamFormat.AES_IV_BYTES + ciphertext.size
+ val frame = ByteArray(TransportStreamFormat.FRAME_LENGTH_BYTES + frameLen)
+ frame[0] = ((frameLen shr 24) and 0xFF).toByte()
+ frame[1] = ((frameLen shr 16) and 0xFF).toByte()
+ frame[2] = ((frameLen shr 8) and 0xFF).toByte()
+ frame[3] = (frameLen and 0xFF).toByte()
+ iv.copyInto(frame, destinationOffset = TransportStreamFormat.FRAME_LENGTH_BYTES)
+ ciphertext.copyInto(
+ frame,
+ destinationOffset = TransportStreamFormat.FRAME_LENGTH_BYTES + TransportStreamFormat.AES_IV_BYTES,
+ )
+ return frame
+}
+
+internal suspend fun encryptPlaintextFileToFcaeBlob(
+ sourceUri: String,
+ writeBytes: suspend (bytes: ByteArray) -> Unit,
+ finish: suspend () -> Long,
+ transportPublicKeyB64: String,
+ ephemeralSecretKey: ByteArray,
+ plaintextSizeBytes: Long,
+ onPlaintextProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)?,
+): Long {
+ val key = deriveTransportFileAesKey(transportPublicKeyB64, ephemeralSecretKey)
+ try {
+ writeBytes(TransportStreamFormat.MAGIC.encodeToByteArray())
+ writeBytes(byteArrayOf(TransportStreamFormat.VERSION))
+ var bytesRead = 0L
+ val readBuffer = ByteArray(TransportStreamFormat.PLAINTEXT_CHUNK_BYTES)
+ val input = openOutboundFileInputStream(sourceUri)
+ ?: error("Failed to open outbound file for streaming encrypt")
+ try {
+ while (true) {
+ val n = input.read(readBuffer, 0, readBuffer.size)
+ if (n <= 0) break
+ val (iv, ciphertext) = aesGcmEncryptChunk(key, readBuffer.copyOf(n))
+ writeBytes(buildAesTransportFrame(iv, ciphertext))
+ bytesRead += n
+ val progressTotal = plaintextSizeBytes.takeIf { it > 0L } ?: bytesRead
+ onPlaintextProgress?.invoke(bytesRead, progressTotal)
+ }
+ } finally {
+ input.close()
+ }
+ return finish()
+ } finally {
+ key.fill(0)
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.kt
new file mode 100644
index 0000000..b13edac
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.kt
@@ -0,0 +1,35 @@
+package ru.fromchat.crypto.transport
+
+private const val TRANSPORT_FILE_KEY_CONTEXT = "fromchat_transport_file_v1"
+
+internal expect fun deriveTransportFileAesKey(
+ transportPublicKeyB64: String,
+ ephemeralSecretKey: ByteArray,
+): ByteArray
+
+internal expect suspend fun aesGcmEncryptChunk(
+ key: ByteArray,
+ plaintext: ByteArray,
+): Pair
+
+internal expect fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray
+
+/** Matches Python `derive_key_from_shared_secret` (HKDF-SHA256, 16 zero salt). */
+internal fun hkdfTransportFileKey(sharedSecret: ByteArray): ByteArray {
+ val salt = ByteArray(16)
+ val prk = hmacSha256(salt, sharedSecret)
+ val info = TRANSPORT_FILE_KEY_CONTEXT.encodeToByteArray()
+ val okm = ByteArray(32)
+ var t = byteArrayOf()
+ var offset = 0
+ var counter = 1
+ while (offset < okm.size) {
+ val input = t + info + counter.toByte()
+ t = hmacSha256(prk, input)
+ val copyLen = minOf(t.size, okm.size - offset)
+ t.copyInto(okm, destinationOffset = offset, startIndex = 0, endIndex = copyLen)
+ offset += copyLen
+ counter++
+ }
+ return okm
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportStreamFormat.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportStreamFormat.kt
new file mode 100644
index 0000000..c67f64b
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportStreamFormat.kt
@@ -0,0 +1,23 @@
+package ru.fromchat.crypto.transport
+
+/**
+ * Streaming transport file blob (AES-256-GCM, chunked).
+ * Layout: "FCAE" | version(1) | frames…
+ * Each frame: uint32_be(frame_len) | iv(12) | aes_gcm_ciphertext (plaintext chunk + tag).
+ * Legacy blobs: nonce(24) | box_ciphertext (no magic).
+ */
+object TransportStreamFormat {
+ const val MAGIC = "FCAE"
+ const val VERSION: Byte = 1
+ const val AES_IV_BYTES = 12
+ const val PLAINTEXT_CHUNK_BYTES = 256 * 1024
+ const val FRAME_LENGTH_BYTES = 4
+
+ fun isStreamBlobPrefix(prefix: ByteArray): Boolean =
+ prefix.size >= MAGIC.length &&
+ prefix[0] == MAGIC[0].code.toByte() &&
+ prefix[1] == MAGIC[1].code.toByte() &&
+ prefix[2] == MAGIC[2].code.toByte() &&
+ prefix[3] == MAGIC[3].code.toByte()
+}
+
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
index e2d70f2..5b443f6 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -41,6 +41,7 @@ import androidx.navigation.navArgument
import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import coil3.svg.SvgDecoder
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement
@@ -52,6 +53,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground
import ru.fromchat.api.ApiClient
+import ru.fromchat.api.AttachmentDownloadNotifier
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.UserStatusStore
@@ -189,49 +191,16 @@ fun App(
}
var startDestination by remember { mutableStateOf(null) }
+ var sessionLogoutRequired by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
- runCatching {
- Config.initialize()
+ kotlinx.coroutines.withContext(Dispatchers.Default) {
+ runCatching { Config.initialize() }
+ runCatching { ensureFromChatCacheGeneration() }
+ runCatching { NetworkConnectivity.ensureStarted() }
+ runCatching { ApiClient.loadPersistedData() }
}
- runCatching { ensureFromChatCacheGeneration() }
-
- runCatching { NetworkConnectivity.ensureStarted() }
-
- // Load persisted token and user data
- ApiClient.loadPersistedData()
-
- val hasTokenForBootstrap = ApiClient.token?.isNotEmpty() == true
- if (hasTokenForBootstrap) {
- when (ru.fromchat.core.instance.bootstrapSessionInstance(hasToken = true)) {
- ru.fromchat.core.instance.SessionBootstrapResult.LogoutRequired -> {
- ru.fromchat.core.instance.logoutIfInstanceUnsupported()
- startDestination = "login"
- return@LaunchedEffect
- }
- ru.fromchat.core.instance.SessionBootstrapResult.OfflineCached,
- ru.fromchat.core.instance.SessionBootstrapResult.Ready,
- -> Unit
- }
- }
-
- runCatching { ProfileCache.hydrateFromDisk() }
-
- val hasTokenInitially = ApiClient.token?.isNotEmpty() == true
- if (hasTokenInitially) {
- runCatching {
- val ownProfile = ApiClient.getOwnProfile()
- ApiClient.syncSuspensionStateFromProfile(ownProfile)
- }
- }
-
- // Initialize update sync state for the current user (if any)
- runCatching {
- UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
- }
-
- // Now determine start destination based on loaded token
val hasToken = ApiClient.token?.isNotEmpty() == true
startDestination = when {
hasToken && startAtDmConversationUserId != null -> "chat"
@@ -239,6 +208,34 @@ fun App(
hasToken && !startAtPublicChat -> "chat"
else -> "login"
}
+
+ runCatching {
+ UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
+ }
+
+ ru.fromchat.core.DeferredStartupNetwork.scheduleAfterUiVisible()
+
+ if (!hasToken) return@LaunchedEffect
+
+ launch(Dispatchers.Default) {
+ runCatching {
+ ru.fromchat.core.instance.bootstrapSessionOnStartup(
+ hasToken = true,
+ onLogoutRequired = { sessionLogoutRequired = true },
+ )
+ }
+ }
+
+ launch(Dispatchers.Default) {
+ runCatching { ProfileCache.hydrateFromDisk() }
+ }
+ }
+
+ LaunchedEffect(sessionLogoutRequired) {
+ if (!sessionLogoutRequired) return@LaunchedEffect
+ ru.fromchat.core.instance.logoutIfInstanceUnsupported()
+ startDestination = "login"
+ sessionLogoutRequired = false
}
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]).
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadScheduler.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadScheduler.kt
index c7922ea..16bd645 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadScheduler.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadScheduler.kt
@@ -1,22 +1,34 @@
package ru.fromchat.ui.chat
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
+import kotlin.coroutines.coroutineContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import ru.fromchat.api.AttachmentDownloadForeground
+import ru.fromchat.api.AttachmentDownloadNotifier
/**
- * Limits concurrent DM attachment decrypt/download work to [MAX_PARALLEL].
- * Additional requests wait in a priority queue (visible messages first).
+ * Limits concurrent DM attachment decrypt/download work to [MAX_PARALLEL] across **different** keys.
+ * The same [storageKey] never runs more than one download at a time; duplicate callers share one result.
*/
object AttachmentDownloadScheduler {
private const val MAX_PARALLEL = 2
+ init {
+ AttachmentDownloadNotifier.bindInFlightCheck { storageKey -> isActive(storageKey) }
+ }
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mutex = Mutex()
+ private val runMutexByKey = mutableMapOf()
private data class Pending(
val storageKey: String,
@@ -24,46 +36,85 @@ object AttachmentDownloadScheduler {
val enqueuedAt: Long,
val work: suspend () -> String?,
val result: CompletableDeferred,
+ val keepAliveInBackground: Boolean,
)
+ fun isActive(storageKey: String): Boolean =
+ activeJobs[storageKey]?.isActive == true
+
private val waiting = mutableListOf()
private val keyToDeferred = mutableMapOf>()
+ private val activeJobs = mutableMapOf()
private var activeCount = 0
+ private fun runMutexFor(storageKey: String): Mutex =
+ runMutexByKey.getOrPut(storageKey) { Mutex() }
+
/**
- * Runs [work] when a download slot is available. Duplicate [storageKey] shares one result.
+ * Cancels queued and in-flight work for [storageKey]. Different keys are unaffected.
+ */
+ suspend fun cancel(storageKey: String) {
+ runMutexFor(storageKey).withLock {
+ val job = mutex.withLock {
+ waiting.removeAll { it.storageKey == storageKey }
+ keyToDeferred[storageKey]?.let { deferred ->
+ if (!deferred.isCompleted) {
+ deferred.complete(null)
+ }
+ }
+ activeJobs.remove(storageKey)
+ }
+ job?.cancelAndJoin()
+ mutex.withLock {
+ keyToDeferred.remove(storageKey)
+ }
+ }
+ }
+
+ /**
+ * Runs [work] when a global slot is available. Duplicate [storageKey] shares one result and one job.
*/
suspend fun run(
storageKey: String,
messageId: Int,
work: suspend () -> String?,
- ): String? {
+ keepAliveInBackground: Boolean = false,
+ ): String? = runMutexFor(storageKey).withLock {
+ val existing = mutex.withLock {
+ keyToDeferred[storageKey]?.takeIf { !it.isCompleted }
+ }
+ if (existing != null) {
+ return existing.await()
+ }
+
+ mutex.withLock { activeJobs[storageKey] }
+ ?.takeIf { it.isActive }
+ ?.cancelAndJoin()
+
val deferred = mutex.withLock {
- keyToDeferred[storageKey] ?: run {
- val created = CompletableDeferred()
- keyToDeferred[storageKey] = created
- waiting.add(
- Pending(
- storageKey = storageKey,
- messageId = messageId,
- enqueuedAt = AttachmentMediaLog.nowMs(),
- work = work,
- result = created,
- ),
- )
- sortWaitingLocked()
- created
- }
+ val created = CompletableDeferred()
+ keyToDeferred[storageKey] = created
+ waiting.removeAll { it.storageKey == storageKey }
+ waiting.add(
+ Pending(
+ storageKey = storageKey,
+ messageId = messageId,
+ enqueuedAt = AttachmentMediaLog.nowMs(),
+ work = work,
+ result = created,
+ keepAliveInBackground = keepAliveInBackground,
+ ),
+ )
+ sortWaitingLocked()
+ created
}
pumpLocked()
- return deferred.await()
+ deferred.await()
}
fun reprioritize() {
scope.launch {
- mutex.withLock {
- sortWaitingLocked()
- }
+ mutex.withLock { sortWaitingLocked() }
pumpLocked()
}
}
@@ -72,30 +123,75 @@ object AttachmentDownloadScheduler {
val toStart = mutex.withLock {
val jobs = mutableListOf()
while (activeCount < MAX_PARALLEL && waiting.isNotEmpty()) {
- val next = waiting.removeAt(0)
+ val next = waiting.first()
+ if (activeJobs[next.storageKey]?.isActive == true) {
+ break
+ }
+ waiting.removeAt(0)
activeCount++
jobs.add(next)
}
jobs
}
for (pending in toStart) {
- scope.launch {
- runPending(pending)
+ val job = scope.launch {
+ try {
+ runPending(pending)
+ } finally {
+ mutex.withLock {
+ activeJobs.remove(pending.storageKey)
+ }
+ }
+ }
+ mutex.withLock {
+ activeJobs[pending.storageKey] = job
}
}
}
private suspend fun runPending(pending: Pending) {
- val outcome = runCatching { pending.work() }
- mutex.withLock {
- activeCount = (activeCount - 1).coerceAtLeast(0)
- keyToDeferred.remove(pending.storageKey)
+ if (pending.keepAliveInBackground) {
+ AttachmentDownloadForeground.onFileDownloadStarted(pending.storageKey)
+ }
+ try {
+ val outcome = pending.work()
+ mutex.withLock {
+ if (!pending.result.isCompleted) {
+ pending.result.complete(outcome)
+ }
+ if (keyToDeferred[pending.storageKey] === pending.result) {
+ keyToDeferred.remove(pending.storageKey)
+ }
+ }
+ } catch (error: CancellationException) {
+ mutex.withLock {
+ if (!pending.result.isCompleted) {
+ pending.result.complete(null)
+ }
+ if (keyToDeferred[pending.storageKey] === pending.result) {
+ keyToDeferred.remove(pending.storageKey)
+ }
+ }
+ throw error
+ } catch (error: Throwable) {
+ mutex.withLock {
+ if (!pending.result.isCompleted) {
+ pending.result.completeExceptionally(error)
+ }
+ if (keyToDeferred[pending.storageKey] === pending.result) {
+ keyToDeferred.remove(pending.storageKey)
+ }
+ }
+ throw error
+ } finally {
+ if (pending.keepAliveInBackground) {
+ AttachmentDownloadForeground.onFileDownloadFinished(pending.storageKey)
+ }
+ mutex.withLock {
+ activeCount = (activeCount - 1).coerceAtLeast(0)
+ }
+ pumpLocked()
}
- outcome.fold(
- onSuccess = { pending.result.complete(it) },
- onFailure = { pending.result.completeExceptionally(it) },
- )
- pumpLocked()
}
private fun sortWaitingLocked() {
@@ -106,3 +202,15 @@ object AttachmentDownloadScheduler {
)
}
}
+
+internal fun checkAttachmentDownloadActive(storageKey: String) {
+ if (AttachmentDownloadNotifier.isCancelled(storageKey)) {
+ throw CancellationException("attachment download cancelled")
+ }
+}
+
+/** Cooperative cancel check for in-flight decrypt/download loops. */
+internal suspend fun ensureAttachmentDownloadActive(storageKey: String) {
+ coroutineContext.ensureActive()
+ checkAttachmentDownloadActive(storageKey)
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.kt
index 23edbac..c270c35 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.kt
@@ -10,3 +10,6 @@ import androidx.compose.runtime.Composable
expect fun rememberCreateDownloadDestinationLauncher(
onDestination: (String?) -> Unit,
): (filename: String, mimeType: String) -> Unit
+
+/** Re-applies persistable URI permission for a stored SAF export URI (best-effort). */
+expect suspend fun persistExportUriPermissionIfNeeded(exportUri: String)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMime.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMime.kt
index 1557382..8642480 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMime.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMime.kt
@@ -22,6 +22,8 @@ fun mimeTypeForFilename(filename: String): String {
"xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
"ppt" -> "application/vnd.ms-powerpoint"
"pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation"
+ "apk", "apks", "xapk", "apkm" -> "application/vnd.android.package-archive"
+ "dmg" -> "application/x-apple-diskimage"
else -> "application/octet-stream"
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.kt
new file mode 100644
index 0000000..5354870
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.kt
@@ -0,0 +1,3 @@
+package ru.fromchat.ui.chat
+
+internal expect fun showAttachmentOpenFailed(message: String)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt
index e224e1d..f99bb84 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt
@@ -1,6 +1,7 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
@@ -48,6 +49,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.material3.TextButton
import kotlinx.coroutines.Dispatchers
@@ -81,11 +83,16 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.AttachmentDownloadNotifier
+import ru.fromchat.ui.scaleOnPress
import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile
import ru.fromchat.attachment_image_load_failed
import ru.fromchat.attachment_retry
+import ru.fromchat.attachment_upload_failed
+import ru.fromchat.attachment_upload_failed_too_large
import ru.fromchat.cd_attachment_retry
+import ru.fromchat.cd_attachment_upload_retry
+import ru.fromchat.core.cache.UPLOAD_ERROR_FILE_TOO_LARGE
private val IMAGE_SIZE = 160.dp
private const val BLUR_FADE_MS = 450
@@ -108,6 +115,8 @@ fun AttachmentPreview(
awaitingServerAck: Boolean = false,
/** 0–100 upload progress when isUploading; null = indefinite */
uploadProgress: Int? = null,
+ uploadError: String? = null,
+ onRetryUpload: (() -> Unit)? = null,
fileThumbnail: String? = null,
fileAspectRatio: Float? = null,
fileSizeBytes: Long? = null,
@@ -120,7 +129,8 @@ fun AttachmentPreview(
isAuthor: Boolean = false,
/** Message text shown in attachment download/upload logs. */
messageLabel: String? = null,
- modifier: Modifier = Modifier
+ onCancelUpload: (() -> Unit)? = null,
+ modifier: Modifier = Modifier,
) {
val isImage = when {
file != null -> isImageFilename(file.name)
@@ -157,7 +167,10 @@ fun AttachmentPreview(
isAuthor = isAuthor,
isUploading = isPendingFile && (isUploading || awaitingServerAck),
uploadProgress = if (isPendingFile) uploadProgress else null,
+ uploadError = if (isPendingFile) uploadError else null,
+ onRetryUpload = if (isPendingFile) onRetryUpload else null,
messageLabel = messageLabel,
+ onCancelUpload = onCancelUpload,
modifier = modifier,
)
}
@@ -235,7 +248,10 @@ fun AttachmentPreview(
isUploading = isUploading,
awaitingServerAck = awaitingServerAck,
uploadProgress = uploadProgress,
+ uploadError = uploadError,
+ onRetryUpload = onRetryUpload,
messageLabel = messageLabel,
+ onCancelUpload = onCancelUpload,
onFullyLoaded = { if (it) isFullyLoaded = true },
)
}
@@ -262,9 +278,13 @@ private fun ChatImageTileContent(
isUploading: Boolean,
awaitingServerAck: Boolean,
uploadProgress: Int?,
+ uploadError: String? = null,
+ onRetryUpload: (() -> Unit)? = null,
messageLabel: String? = null,
+ onCancelUpload: (() -> Unit)? = null,
onFullyLoaded: (Boolean) -> Unit = {},
) {
+ val scope = rememberCoroutineScope()
val clipShape = attachmentImageCornerShape(isAuthor)
val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f }
@@ -315,6 +335,17 @@ private fun ChatImageTileContent(
val decryptFailed = remember(downloadProgressByKey, messageId, fileIndex, cacheClientId) {
AttachmentDownloadNotifier.isFailed(messageId, fileIndex, cacheClientId)
}
+ val downloadCancelled = remember(downloadProgressByKey, messageId, fileIndex, cacheClientId) {
+ AttachmentDownloadNotifier.isCancelled(messageId, fileIndex, cacheClientId) ||
+ AttachmentDownloadNotifier.hasResumablePartial(messageId, fileIndex, cacheClientId)
+ }
+ LaunchedEffect(messageId, fileIndex, cacheClientId) {
+ AttachmentDownloadNotifier.restorePausedForAttachment(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = cacheClientId,
+ )
+ }
var isAwaitingNetworkFull by remember(decryptCacheKey) { mutableStateOf(false) }
var loadAttempt by remember(decryptCacheKey) { mutableIntStateOf(0) }
LaunchedEffect(showOutboundBlurOverlay) {
@@ -565,8 +596,9 @@ private fun ChatImageTileContent(
val isDownloadingFullImage = !isOutboundPending && fullBitmap == null &&
(downloadProgress != null || isAwaitingNetworkFull)
- val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay
- val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !isOutboundPending
+ val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay && !downloadCancelled
+ val showDownloadCancelledOverlay = downloadCancelled && fullBitmap == null && !isOutboundPending
+ val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !isOutboundPending && !downloadCancelled
val showSpinnerOnly = fullBitmap == null && thumbBitmap == null && !hasLocalSource &&
!showLoadFailedOverlay &&
!showOutboundBlurOverlay &&
@@ -634,19 +666,62 @@ private fun ChatImageTileContent(
)
LaunchedEffect(full) { onFullyLoaded(true) }
}
- if (showDownloadProgressOverlay) {
+ AnimatedVisibility(
+ visible = showDownloadProgressOverlay,
+ enter = scaleIn(
+ initialScale = 0.82f,
+ animationSpec = tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing),
+ ) + fadeIn(tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing)),
+ exit = scaleOut(
+ targetScale = 0.82f,
+ animationSpec = tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing),
+ ) + fadeOut(tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing)),
+ ) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
- ExpressiveUploadIndicator(
- uploadProgress = downloadProgress,
+ CancellableAttachmentProgressIndicator(
+ progress = downloadProgress,
+ onCancel = {
+ scope.launch {
+ AttachmentDownloadNotifier.cancelDownload(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = cacheClientId,
+ )
+ AttachmentDownloadScheduler.cancel(decryptCacheKey)
+ }
+ },
+ showCloseScrim = true,
modifier = Modifier.size(48.dp),
)
}
- } else if (showSpinnerOnly) {
+ }
+ AnimatedVisibility(
+ visible = showDownloadCancelledOverlay,
+ enter = scaleIn(
+ initialScale = 0.82f,
+ animationSpec = tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing),
+ ) + fadeIn(tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing)),
+ exit = scaleOut(
+ targetScale = 0.82f,
+ animationSpec = tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing),
+ ) + fadeOut(tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing)),
+ ) {
+ DownloadCancelledImageOverlay(
+ isAuthor = isAuthor,
+ onRetryDownload = {
+ AttachmentDownloadNotifier.beginDownload(messageId, fileIndex, cacheClientId)
+ decryptFinished = false
+ loadAttempt++
+ },
+ modifier = Modifier.matchParentSize(),
+ )
+ }
+ if (showSpinnerOnly) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
@@ -658,7 +733,7 @@ private fun ChatImageTileContent(
AttachmentImageLoadFailedOverlay(
isAuthor = isAuthor,
onRetry = {
- AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
+ AttachmentDownloadNotifier.beginDownload(messageId, fileIndex, cacheClientId)
decryptFinished = false
ApiClient.clearPartialEncryptedDownload(decryptCacheKey)
loadAttempt++
@@ -674,11 +749,60 @@ private fun ChatImageTileContent(
uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null,
clipShape = clipShape,
contentScale = imageContentScale,
+ onCancelUpload = if (isUploading && !awaitingServerAck) onCancelUpload else null,
modifier = Modifier
.matchParentSize()
.alpha(outboundOverlayAlpha.value),
)
}
+ if (isOutboundPending && !uploadError.isNullOrBlank() && onRetryUpload != null) {
+ AttachmentUploadFailedOverlay(
+ isAuthor = isAuthor,
+ errorKey = uploadError,
+ onRetry = onRetryUpload,
+ modifier = Modifier.matchParentSize(),
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalHazeMaterialsApi::class)
+@Composable
+private fun DownloadCancelledImageOverlay(
+ isAuthor: Boolean,
+ onRetryDownload: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val scrim = MaterialTheme.colorScheme.scrim.copy(alpha = 0.38f)
+ Box(
+ modifier = modifier
+ .hazeEffect(style = HazeMaterials.thin())
+ .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Surface(
+ shape = CircleShape,
+ color = scrim,
+ modifier = Modifier
+ .size(48.dp)
+ .scaleOnPress(scale = 0.92f, onClick = onRetryDownload, indication = null),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Download,
+ contentDescription = null,
+ modifier = Modifier.size(26.dp),
+ tint = if (isAuthor) {
+ Color.White
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+ }
+ }
}
}
@@ -689,6 +813,7 @@ private fun UploadingImageOverlay(
uploadProgress: Int?,
clipShape: RoundedCornerShape,
contentScale: ContentScale = ContentScale.Fit,
+ onCancelUpload: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
@@ -711,17 +836,26 @@ private fun UploadingImageOverlay(
.padding(16.dp),
contentAlignment = Alignment.Center
) {
- ExpressiveUploadIndicator(
- uploadProgress = uploadProgress,
- modifier = Modifier.size(56.dp)
- )
+ if (onCancelUpload != null) {
+ CancellableAttachmentProgressIndicator(
+ progress = uploadProgress,
+ onCancel = onCancelUpload,
+ showCloseScrim = false,
+ modifier = Modifier.size(56.dp),
+ )
+ } else {
+ ExpressiveUploadIndicator(
+ uploadProgress = uploadProgress,
+ modifier = Modifier.size(56.dp),
+ )
+ }
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
-private fun ExpressiveUploadIndicator(
+internal fun ExpressiveUploadIndicator(
uploadProgress: Int?,
modifier: Modifier = Modifier,
indicatorColor: Color? = null,
@@ -903,6 +1037,45 @@ internal fun decodeAttachmentThumbnailBase64(value: String): ByteArray? {
return runCatching { Base64.decode(payload) }.getOrNull()
}
+@Composable
+private fun AttachmentUploadFailedOverlay(
+ isAuthor: Boolean,
+ errorKey: String,
+ onRetry: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val failedText = when (errorKey) {
+ UPLOAD_ERROR_FILE_TOO_LARGE -> stringResource(Res.string.attachment_upload_failed_too_large)
+ else -> stringResource(Res.string.attachment_upload_failed)
+ }
+ val retryText = stringResource(Res.string.attachment_retry)
+ val retryCd = stringResource(Res.string.cd_attachment_upload_retry)
+ val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
+ Box(
+ modifier = modifier
+ .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.padding(horizontal = 12.dp),
+ ) {
+ Text(
+ text = failedText,
+ style = MaterialTheme.typography.bodySmall,
+ color = headlineColor,
+ )
+ TextButton(
+ onClick = onRetry,
+ modifier = Modifier.semantics { contentDescription = retryCd },
+ ) {
+ Text(text = retryText, color = headlineColor)
+ }
+ }
+ }
+}
+
@Composable
private fun AttachmentImageLoadFailedOverlay(
isAuthor: Boolean,
@@ -973,11 +1146,13 @@ internal fun ExpressiveFileAttachmentRow(
filename: String,
sizeBytes: Long?,
onClick: (() -> Unit)?,
+ enableClick: Boolean = onClick != null,
isAuthor: Boolean,
isUploading: Boolean,
uploadProgress: Int?,
isDownloaded: Boolean = false,
- modifier: Modifier = Modifier
+ onCancelProgress: (() -> Unit)? = null,
+ modifier: Modifier = Modifier,
) {
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
val supportingColor = if (isAuthor) {
@@ -989,10 +1164,14 @@ internal fun ExpressiveFileAttachmentRow(
Row(
modifier = modifier
.widthIn(max = 268.dp)
- .padding(horizontal = 12.dp, vertical = 8.dp)
+ .padding(horizontal = 6.dp, vertical = 8.dp)
.then(
- if (onClick != null && !isUploading) {
- Modifier.clickable(onClick = onClick)
+ if (onClick != null && enableClick) {
+ Modifier.scaleOnPress(
+ scale = 0.96f,
+ onClick = onClick,
+ indication = null,
+ )
} else {
Modifier
}
@@ -1000,53 +1179,14 @@ internal fun ExpressiveFileAttachmentRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
- Box(
+ FileAttachmentLeadingSlot(
+ isProgressing = isUploading,
+ isDownloaded = isDownloaded,
+ uploadProgress = uploadProgress,
+ isAuthor = isAuthor,
+ onCancelProgress = onCancelProgress ?: {},
modifier = Modifier.size(leadingSize),
- contentAlignment = Alignment.Center
- ) {
- if (isUploading) {
- ExpressiveUploadIndicator(
- uploadProgress = uploadProgress,
- modifier = Modifier.size(leadingSize),
- indicatorColor = if (isAuthor) Color.White else null,
- trackColorOverride = if (isAuthor) {
- Color.White.copy(alpha = 0.28f)
- } else {
- null
- }
- )
- } else {
- Surface(
- shape = CircleShape,
- color = if (isAuthor) {
- Color.White.copy(alpha = 0.22f)
- } else {
- MaterialTheme.colorScheme.secondaryContainer
- },
- modifier = Modifier.size(leadingSize)
- ) {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = if (isDownloaded) {
- Icons.Rounded.InsertDriveFile
- } else {
- Icons.Rounded.Download
- },
- contentDescription = null,
- modifier = Modifier.size(26.dp),
- tint = if (isAuthor) {
- Color.White
- } else {
- MaterialTheme.colorScheme.onSecondaryContainer
- }
- )
- }
- }
- }
- }
+ )
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/CancellableAttachmentProgress.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/CancellableAttachmentProgress.kt
new file mode 100644
index 0000000..d69ebed
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/CancellableAttachmentProgress.kt
@@ -0,0 +1,78 @@
+package ru.fromchat.ui.chat
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import ru.fromchat.Res
+import ru.fromchat.cd_close
+import ru.fromchat.ui.scaleOnPress
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+internal fun CancellableAttachmentProgressIndicator(
+ progress: Int?,
+ onCancel: () -> Unit,
+ modifier: Modifier = Modifier,
+ indicatorColor: Color? = null,
+ trackColorOverride: Color? = null,
+ /** Dark circle behind the close icon — only for image attachment download UI. */
+ showCloseScrim: Boolean = false,
+) {
+ val closeLabel = stringResource(Res.string.cd_close)
+ val scrim = MaterialTheme.colorScheme.scrim.copy(alpha = 0.42f)
+ Box(
+ modifier = modifier
+ .scaleOnPress(
+ scale = 0.92f,
+ onClick = onCancel,
+ indication = null,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ ExpressiveUploadIndicator(
+ uploadProgress = progress,
+ modifier = Modifier.fillMaxSize(),
+ indicatorColor = indicatorColor,
+ trackColorOverride = trackColorOverride,
+ )
+ if (showCloseScrim) {
+ Surface(
+ shape = CircleShape,
+ color = scrim,
+ modifier = Modifier.size(28.dp),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Close,
+ contentDescription = closeLabel,
+ modifier = Modifier.size(18.dp),
+ tint = Color.White,
+ )
+ }
+ }
+ } else {
+ Icon(
+ imageVector = Icons.Rounded.Close,
+ contentDescription = closeLabel,
+ modifier = Modifier.size(22.dp),
+ tint = indicatorColor ?: MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatFileAttachmentTile.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatFileAttachmentTile.kt
index 19a302a..13c6bb7 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatFileAttachmentTile.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatFileAttachmentTile.kt
@@ -1,23 +1,14 @@
package ru.fromchat.ui.chat
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.background
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.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -29,10 +20,18 @@ 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.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
+import org.jetbrains.compose.resources.stringResource
+import ru.fromchat.Res
+import ru.fromchat.attachment_open_failed
+import ru.fromchat.attachment_retry
+import ru.fromchat.attachment_upload_failed
+import ru.fromchat.attachment_upload_failed_too_large
+import ru.fromchat.cd_attachment_upload_retry
+import ru.fromchat.core.cache.UPLOAD_ERROR_FILE_TOO_LARGE
import ru.fromchat.api.AttachmentDownloadNotifier
import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile
@@ -52,35 +51,36 @@ fun ChatFileAttachmentTile(
isAuthor: Boolean,
isUploading: Boolean,
uploadProgress: Int?,
+ uploadError: String? = null,
+ onRetryUpload: (() -> Unit)? = null,
messageLabel: String? = null,
+ onCancelUpload: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
+ val openFailedMessage = stringResource(Res.string.attachment_open_failed)
val isPendingLocal = pendingFileUri != null && file == null
+ val uploadFailed = isPendingLocal && !uploadError.isNullOrBlank()
val mimeType = remember(filename) { mimeTypeForFilename(filename) }
- var exportUri by remember(messageId, fileIndex, clientMessageId) {
+ var cacheUri by remember(messageId, fileIndex, clientMessageId) {
mutableStateOf(null)
}
- var uriAccessible by remember { mutableStateOf(false) }
-
- LaunchedEffect(messageId, fileIndex, clientMessageId) {
- val stored = DownloadedFileRegistry.getExportUri(messageId, fileIndex, clientMessageId)
- exportUri = stored
- uriAccessible = stored != null && isExportUriAccessible(stored)
- if (stored != null && !uriAccessible) {
- DownloadedFileRegistry.removeExportUri(messageId, fileIndex, clientMessageId)
- exportUri = null
- AttachmentDownloadNotifier.clearProgress(
- messageId = messageId,
- fileIndex = fileIndex,
- clientMessageId = clientMessageId,
- mirrorAsFileAttachment = true,
- )
- }
- }
val downloadProgressByKey by AttachmentDownloadNotifier.progressPercentByKey.collectAsState()
+ val downloadCancelledKeys by AttachmentDownloadNotifier.cancelledKeys.collectAsState()
+ LaunchedEffect(messageId, fileIndex, clientMessageId) {
+ AttachmentDownloadNotifier.restorePausedForAttachment(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+ }
+
+ LaunchedEffect(messageId, fileIndex, clientMessageId, pendingFileUri, downloadProgressByKey, downloadCancelledKeys) {
+ cacheUri = DecryptedFileCache.getCached(messageId, fileIndex, clientMessageId)
+ }
val downloadProgress = remember(downloadProgressByKey, messageId, fileIndex, clientMessageId) {
DownloadedFileRegistry.resolveDownloadPercent(
messageId = messageId,
@@ -89,123 +89,179 @@ fun ChatFileAttachmentTile(
progressByKey = downloadProgressByKey,
)
}
- val isDownloading = !isUploading &&
- downloadProgress != null &&
- downloadProgress < 100
-
- var pendingDownload by remember { mutableStateOf(null) }
-
- val launchDestinationPicker = rememberCreateDownloadDestinationLauncher { destination ->
- val pending = pendingDownload
- pendingDownload = null
- if (destination == null || pending == null) return@rememberCreateDownloadDestinationLauncher
- scope.launch {
- val ok = DmFileDownloader.downloadToExportUri(
- messageId = pending.messageId,
- fileIndex = pending.fileIndex,
- file = pending.file,
- envelope = pending.envelope,
- currentUserId = pending.currentUserId,
- clientMessageId = pending.clientMessageId,
- exportUri = destination,
- messageLabel = pending.messageLabel,
- )
- if (ok) {
- uriAccessible = isExportUriAccessible(destination)
- if (!uriAccessible) {
- DownloadedFileRegistry.removeExportUri(
- pending.messageId,
- pending.fileIndex,
- pending.clientMessageId,
- )
- exportUri = null
- }
- } else {
- exportUri = null
- uriAccessible = false
- }
- }
+ val downloadPaused = remember(downloadCancelledKeys, messageId, fileIndex, clientMessageId) {
+ AttachmentDownloadNotifier.isCancelled(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
}
+ val isDownloading = !isUploading && !uploadFailed &&
+ downloadProgress != null &&
+ downloadProgress < 100 &&
+ !downloadPaused
- val isDownloaded = !isPendingLocal && uriAccessible && exportUri != null && !isDownloading
- val showWavy = isUploading || isDownloading
+ val showPausedProgress = downloadPaused && downloadProgress != null && downloadProgress < 100
+ val resolvedCacheUri = cacheUri
+ ?: DecryptedFileCache.getCached(messageId, fileIndex, clientMessageId)
+ val isCached = resolvedCacheUri != null && !isDownloading
+ val showWavy = isUploading || isDownloading || showPausedProgress
+ val showProgressing = showWavy && !uploadFailed
+ val showAsDownloadedIcon = isCached && !showProgressing
+ val displayUploadProgress = if (isUploading) uploadProgress ?: 0 else uploadProgress
+ val openableLocalUri = resolvedCacheUri
+ ?: pendingFileUri?.takeIf { isPendingLocal }
val onRowClick: (() -> Unit)? = when {
- isUploading -> null
- isPendingLocal && pendingFileUri != null -> {
+ openableLocalUri != null -> {
{
scope.launch {
- withContext(Dispatchers.Default) {
- openExportUri(pendingFileUri, mimeType)
+ val opened = openCachedAttachmentFile(openableLocalUri, mimeType, filename)
+ if (!opened) {
+ showAttachmentOpenFailed(openFailedMessage)
}
}
}
}
- isDownloaded && exportUri != null -> {
+ downloadPaused && file != null && dmEnvelope != null -> {
{
+ AttachmentDownloadNotifier.beginDownload(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
scope.launch {
- val accessible = isExportUriAccessible(exportUri!!)
- if (!accessible) {
- DownloadedFileRegistry.removeExportUri(messageId, fileIndex, clientMessageId)
- exportUri = null
- uriAccessible = false
+ val ok = DmFileDownloader.downloadToCache(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ file = file,
+ envelope = dmEnvelope,
+ currentUserId = currentUserId,
+ clientMessageId = clientMessageId,
+ messageLabel = messageLabel,
+ )
+ if (ok) {
+ cacheUri = DecryptedFileCache.getCached(messageId, fileIndex, clientMessageId)
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 -> {
+ file != null && dmEnvelope != null && !isDownloading && !downloadPaused -> {
{
- pendingDownload = PendingFileDownload(
+ AttachmentDownloadNotifier.beginDownload(
messageId = messageId,
fileIndex = fileIndex,
- file = file,
- envelope = dmEnvelope,
- currentUserId = currentUserId,
clientMessageId = clientMessageId,
- messageLabel = messageLabel,
+ mirrorAsFileAttachment = true,
)
- launchDestinationPicker(filename, mimeType)
+ scope.launch {
+ val ok = DmFileDownloader.downloadToCache(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ file = file,
+ envelope = dmEnvelope,
+ currentUserId = currentUserId,
+ clientMessageId = clientMessageId,
+ messageLabel = messageLabel,
+ )
+ if (ok) {
+ cacheUri = DecryptedFileCache.getCached(messageId, fileIndex, clientMessageId)
+ AttachmentDownloadNotifier.clearProgress(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+ }
+ }
}
}
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,
- )
-}
+ val enableRowClick = onRowClick != null &&
+ (openableLocalUri != null || !showWavy || downloadPaused)
-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?,
-)
+ val onCancelProgress: (() -> Unit)? = when {
+ isUploading && onCancelUpload != null -> onCancelUpload
+ isDownloading || showPausedProgress -> {
+ {
+ val storageKey = DownloadedFileRegistry.storageKey(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ )
+ scope.launch {
+ AttachmentDownloadNotifier.cancelDownload(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+ AttachmentDownloadScheduler.cancel(storageKey)
+ }
+ }
+ }
+ else -> null
+ }
+
+ val failedLabel = when (uploadError) {
+ UPLOAD_ERROR_FILE_TOO_LARGE -> stringResource(Res.string.attachment_upload_failed_too_large)
+ null, "" -> null
+ else -> stringResource(Res.string.attachment_upload_failed)
+ }
+ val retryText = stringResource(Res.string.attachment_retry)
+ val retryCd = stringResource(Res.string.cd_attachment_upload_retry)
+ val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
+
+ Box(modifier = modifier.widthIn(max = 280.dp)) {
+ ExpressiveFileAttachmentRow(
+ filename = filename,
+ sizeBytes = sizeBytes,
+ onClick = onRowClick,
+ enableClick = enableRowClick,
+ isAuthor = isAuthor,
+ isUploading = showProgressing,
+ uploadProgress = when {
+ isUploading && showProgressing -> displayUploadProgress
+ isDownloading || showPausedProgress -> downloadProgress ?: 0
+ else -> null
+ },
+ isDownloaded = showAsDownloadedIcon,
+ onCancelProgress = onCancelProgress,
+ )
+ if (uploadFailed && failedLabel != null && onRetryUpload != null) {
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(horizontal = 12.dp),
+ ) {
+ Text(
+ text = failedLabel,
+ style = MaterialTheme.typography.bodySmall,
+ color = headlineColor,
+ )
+ TextButton(
+ onClick = onRetryUpload,
+ modifier = Modifier.semantics { contentDescription = retryCd },
+ ) {
+ Text(text = retryText, color = headlineColor)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
index 7989ec7..6ba5ca2 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
@@ -235,6 +235,7 @@ abstract class ChatPanel(
if (cid.isEmpty()) return
if (message.pendingFileUri != null) {
clearOutboundImageCaches(cid, message.id)
+ clearOutboundFileCaches(cid, message.id)
}
removeMessage(message.id)
ru.fromchat.api.outbox.OutgoingMessageCoordinator.cancelOutboundMessage(cid, outboxConversationId())
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
index ae32c90..2e84144 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
@@ -130,6 +130,7 @@ fun ChatScreen(
}
val scope = rememberCoroutineScope()
val saveMessageImage = rememberSaveMessageImage { /* best-effort */ }
+ val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
val haptic = rememberHapticFeedback()
val navController = LocalNavController.current
val profileUserId = panelState.profileUserId
@@ -154,6 +155,13 @@ fun ChatScreen(
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
}
+ var profileSharedSourceMessageId by remember(scrollToMessageId) {
+ mutableStateOf(scrollToMessageId?.takeIf { it > 0 })
+ }
+ LaunchedEffect(scrollToMessageId) {
+ scrollToMessageId?.takeIf { it > 0 }?.let { profileSharedSourceMessageId = it }
+ }
+
val subtitleKey = when {
!online -> "connecting"
connectionStatus == ConnectionStatus.UPDATING -> "updating"
@@ -416,17 +424,22 @@ fun ChatScreen(
if (panel.getRecipientId() != null) {
AttachmentUploadNotifier.progressFlow.collect { progress ->
when (progress) {
+ is AttachmentUploadProgress.Pending ->
+ panel.updateMessageByClientMessageId(progress.jobId) {
+ it.copy(uploadProgress = 0, uploadError = null)
+ }
is AttachmentUploadProgress.InProgress ->
panel.updateMessageByClientMessageId(progress.jobId) {
- it.copy(uploadProgress = progress.percent)
+ it.copy(uploadProgress = progress.percent, uploadError = null)
}
is AttachmentUploadProgress.Success ->
panel.updateMessageByClientMessageId(progress.jobId) {
it.copy(uploadProgress = null)
}
is AttachmentUploadProgress.Failed -> {
- if (progress.error != "Cancelled") {
- scope.launch { panel.cancelQueuedMessageByClientId(progress.jobId) }
+ if (progress.error == "Cancelled") return@collect
+ panel.updateMessageByClientMessageId(progress.jobId) {
+ it.copy(uploadProgress = null, uploadError = progress.error)
}
}
else -> Unit
@@ -505,13 +518,18 @@ fun ChatScreen(
prepareOutboundFileForSend(
clientMessageId = jobId,
sourceUri = att.uri,
+ optimisticMessageId = tempId,
+ displayFilename = att.filename,
)
}
- val fileUri = staged?.stagedUri ?: att.uri
+ if (staged == null) {
+ return@launch
+ }
+ val fileUri = staged.stagedUri
val optimisticMessage = Message(
id = tempId,
user_id = currentUserId ?: -1,
- content = plaintext.ifBlank { att.filename },
+ content = plaintext,
timestamp = nowMessageTimestampIso(),
is_read = false,
is_edited = false,
@@ -526,27 +544,31 @@ fun ChatScreen(
pendingFilename = att.filename,
uploadJobId = jobId,
uploadProgress = 0,
- pendingFileAspectRatio = staged?.aspectRatio ?: aspectRatio,
+ pendingFileAspectRatio = staged.aspectRatio ?: aspectRatio,
fileDimensions = imageDimensions?.let { listOf(it) },
+ fileSizes = staged.sizeBytes.takeIf { it > 0L }?.let { listOf(it) },
)
withContext(Dispatchers.Main) {
panel.addMessage(optimisticMessage)
}
- if (staged == null) {
- withContext(Dispatchers.Main) {
- panel.cancelQueuedMessageByClientId(jobId)
- }
- return@launch
- }
+ AttachmentUploadNotifier.emit(
+ AttachmentUploadProgress.InProgress(
+ jobId = jobId,
+ percent = 1,
+ filename = att.filename,
+ ),
+ messageLabel = plaintext,
+ )
OutgoingMessageCoordinator.enqueueDmAttachment(
recipientId = recipientId,
- plaintext = plaintext.ifBlank { att.filename },
+ plaintext = plaintext,
clientMessageId = jobId,
replyToId = replyToId,
fileUri = fileUri,
filename = att.filename,
optimisticMessage = optimisticMessage,
- aspectRatio = staged?.aspectRatio ?: aspectRatio,
+ aspectRatio = staged.aspectRatio ?: aspectRatio,
+ fileSizeBytes = staged.sizeBytes,
)
}
}
@@ -658,6 +680,7 @@ fun ChatScreen(
message.user_id > 0
) {
{
+ profileSharedSourceMessageId = message.id
ProfileCache.mergePreviewFromPublicMessage(message)
navController.navigate(
"profile/${message.user_id}" +
@@ -677,17 +700,31 @@ fun ChatScreen(
currentUserId = currentUserId,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
+ onCancelOutboundAttachment = { msg ->
+ scope.launch { panel.cancelQueuedMessage(msg) }
+ },
+ onRetryOutboundAttachment = { msg ->
+ val cid = msg.client_message_id?.trim().orEmpty()
+ if (cid.isNotEmpty()) {
+ panel.updateMessageByClientMessageId(cid) {
+ it.copy(uploadError = null, uploadProgress = 0)
+ }
+ ru.fromchat.api.outbox.OutgoingMessageCoordinator
+ .retryDmAttachmentUpload(cid)
+ }
+ },
sharedAvatarNavKey =
if (
panel.supportsNavigateToSenderProfile &&
sharedTransitionScope != null &&
animatedVisibilityScope != null &&
message.user_id != currentUserId &&
- message.user_id > 0
+ message.user_id > 0 &&
+ profileSharedSourceMessageId == message.id
) {
publicChatProfileSharedAvatarKey(
message.user_id,
- message.id
+ message.id,
)
} else {
null
@@ -772,6 +809,11 @@ fun ChatScreen(
onSave = { message ->
resolveSavableMessageImage(message)?.let { savable ->
saveMessageImage(savable)
+ } ?: resolveSavableMessageFile(message)?.let { savable ->
+ saveMessageFile(savable)
+ scope.launch {
+ ensureFileDownloadedForSave(message, savable)
+ }
}
},
onCancelSend = { message ->
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedFileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedFileCache.kt
new file mode 100644
index 0000000..db259d3
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedFileCache.kt
@@ -0,0 +1,406 @@
+package ru.fromchat.ui.chat
+
+import com.pr0gramm3r101.utils.files.PlatformFileSystem
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+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.dm.decryptFailureMessage
+import ru.fromchat.core.cache.copyOutboundFileToPath
+import ru.fromchat.crypto.decryptFileToPath
+
+/**
+ * Disk cache for decrypted non-image DM attachments (bytes on disk, opened via platform URI).
+ */
+object DecryptedFileCache {
+ private const val SUBDIR = "decrypted_files"
+
+ private var cacheDir: String? = null
+ private val cacheMutex = Mutex()
+ private val memoryCache = mutableMapOf()
+
+ fun isDecryptedFileCacheUri(uri: String?): Boolean {
+ if (uri.isNullOrBlank()) return false
+ val path = uri.removePrefix("file://")
+ return path.contains("/$SUBDIR/")
+ }
+
+ fun storageKey(
+ messageId: Int,
+ fileIndex: Int,
+ clientMessageId: String? = null,
+ ): String = DownloadedFileRegistry.storageKey(messageId, fileIndex, clientMessageId)
+
+ fun getCached(
+ messageId: Int,
+ fileIndex: Int,
+ clientMessageId: String? = null,
+ ): String? {
+ val key = storageKey(messageId, fileIndex, clientMessageId)
+ memoryCache[key]?.takeIf { uriFileExists(it) }?.let { return it }
+ return readDisk(key)
+ }
+
+ /** Resolves cache URI for a [DownloadedFileRegistry.storageKey] (server id or client-id key). */
+ fun getCachedUriForStorageKey(storageKey: String): String? {
+ val messageId = DownloadedFileRegistry.messageIdFromStorageKey(storageKey)
+ val fileIndex = DownloadedFileRegistry.fileIndexFromStorageKey(storageKey) ?: 0
+ if (messageId != null && messageId > 0) {
+ getCached(messageId, fileIndex, clientMessageId = null)?.let { return it }
+ }
+ memoryCache[storageKey]?.takeIf { uriFileExists(it) }?.let { return it }
+ return readDisk(storageKey)
+ }
+
+ /**
+ * Copies a local file into the decrypted-file cache using [displayFilename] for the on-disk name
+ * (preserves extension for installers / "Open with").
+ */
+ suspend fun seedFromLocalFile(
+ messageId: Int,
+ fileIndex: Int,
+ localFileUri: String,
+ displayFilename: String,
+ clientMessageId: String? = null,
+ ): String? = withContext(Dispatchers.Default) {
+ val key = storageKey(messageId, fileIndex, clientMessageId)
+ cacheMutex.withLock { resolveUriLocked(key) }?.let { existing ->
+ AttachmentMediaLog.diskCache("file_seed_skip_exists", "key" to key, "uri" to existing)
+ return@withContext existing
+ }
+ val path = diskPath(key, displayFilename) ?: return@withContext null
+ val t0 = AttachmentMediaLog.nowMs()
+ val copied = runCatching {
+ copyOutboundFileToPath(localFileUri, path)
+ }.onFailure {
+ AttachmentMediaLog.diskCache(
+ "file_seed_copy_failed",
+ "key" to key,
+ "src" to localFileUri,
+ "err" to (it.message ?: it::class.simpleName),
+ )
+ }.isSuccess
+ if (!copied || !PlatformFileSystem.exists(path)) {
+ return@withContext null
+ }
+ val uri = cacheMutex.withLock {
+ commitCachePathLocked(key, path)
+ }
+ AttachmentMediaLog.diskCache(
+ if (uri != null) "file_seed_ok" else "file_seed_write_failed",
+ "key" to key,
+ "bytes" to PlatformFileSystem.fileSize(path),
+ "ms" to (AttachmentMediaLog.nowMs() - t0),
+ "uri" to uri,
+ )
+ uri
+ }
+
+ /** After server confirm, copy client-id cache entry to the real message-id key. */
+ suspend fun ensureDiskAliasForMessageId(
+ messageId: Int,
+ fileIndex: Int,
+ clientMessageId: String?,
+ ) {
+ if (messageId <= 0) return
+ val idKey = storageKey(messageId, fileIndex, null)
+ if (getCached(messageId, fileIndex, null) != null) return
+ val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } ?: return
+ val cidKey = storageKey(messageId, fileIndex, cid)
+ val sourceUri = cacheMutex.withLock { resolveUriLocked(cidKey) }
+ ?: readDisk(cidKey)
+ ?: return
+ val sourcePath = sourceUri.removePrefix("file://")
+ if (!PlatformFileSystem.exists(sourcePath)) return
+ val displayName = filenameFromDiskBasename(cidKey, sourcePath.substringAfterLast('/'))
+ ?: sourcePath.substringAfterLast('/')
+ val destPath = diskPath(idKey, displayName) ?: return
+ if (sourcePath == destPath) {
+ cacheMutex.withLock {
+ if (resolveUriLocked(idKey) == null) {
+ commitCachePathLocked(idKey, destPath)
+ }
+ }
+ return
+ }
+ withContext(Dispatchers.Default) {
+ runCatching {
+ copyOutboundFileToPath(sourceUri, destPath)
+ }.onSuccess {
+ cacheMutex.withLock {
+ if (resolveUriLocked(idKey) == null) {
+ commitCachePathLocked(idKey, destPath)
+ AttachmentMediaLog.diskCache("file_alias_ok", "from" to cidKey, "to" to idKey)
+ }
+ }
+ }
+ }
+ }
+
+ suspend fun invalidateForClientMessage(clientMessageId: String) {
+ val cid = clientMessageId.trim()
+ if (cid.isEmpty()) return
+ val prefix = "file_c_${sanitizeKeyPart(cid)}_"
+ val dir = ensureCacheDir() ?: return
+ withContext(Dispatchers.Default) {
+ cacheMutex.withLock {
+ memoryCache.keys.removeAll { it.startsWith(prefix) }
+ }
+ runCatching {
+ PlatformFileSystem.deleteFilesWithPrefix(dir, prefix)
+ }
+ }
+ }
+
+ suspend fun getOrDecrypt(
+ messageId: Int,
+ fileIndex: Int,
+ file: DmFile,
+ envelope: DmEnvelope,
+ currentUserId: Int?,
+ clientMessageId: String? = null,
+ messageLabel: String? = null,
+ ): String? {
+ val key = storageKey(messageId, fileIndex, clientMessageId)
+ getCached(messageId, fileIndex, clientMessageId)?.let { return it }
+ cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
+
+ val label = AttachmentMediaLog.messageLabel(messageLabel)
+ AttachmentDownloadNotifier.beginDownload(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+
+ return withContext(Dispatchers.Default) {
+ runCatching {
+ AttachmentDownloadScheduler.run(
+ storageKey = key,
+ messageId = messageId,
+ keepAliveInBackground = true,
+ work = {
+ decryptAndPersist(
+ key = key,
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ file = file,
+ envelope = envelope,
+ currentUserId = currentUserId,
+ messageLabel = label,
+ )
+ },
+ )
+ }.onFailure { error ->
+ if (error !is CancellationException) {
+ ApiClient.clearPartialEncryptedDownload(key)
+ }
+ }.getOrNull()
+ }
+ }
+
+ private suspend fun decryptAndPersist(
+ key: String,
+ messageId: Int,
+ fileIndex: Int,
+ clientMessageId: String?,
+ file: DmFile,
+ envelope: DmEnvelope,
+ currentUserId: Int?,
+ messageLabel: String?,
+ ): String? {
+ ensureAttachmentDownloadActive(key)
+ cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
+
+ AttachmentDownloadNotifier.emit(
+ AttachmentDownloadProgress.InProgress(key, 1),
+ messageLabel = messageLabel,
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+
+ val outputPath = diskPath(key, file.name)
+ if (outputPath == null) {
+ AttachmentDownloadNotifier.emit(
+ AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
+ messageLabel = messageLabel,
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+ return null
+ }
+
+ try {
+ decryptFileToPath(
+ file = file,
+ envelope = envelope,
+ currentUserId = currentUserId,
+ outputPath = outputPath,
+ downloadResumeKey = key,
+ onDownloadProgress = { percent ->
+ checkAttachmentDownloadActive(key)
+ AttachmentDownloadNotifier.emit(
+ AttachmentDownloadProgress.InProgress(key, percent.coerceIn(0, 100)),
+ messageLabel = messageLabel,
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+ },
+ )
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Throwable) {
+ if (PlatformFileSystem.exists(outputPath)) {
+ PlatformFileSystem.delete(outputPath)
+ }
+ ApiClient.clearPartialEncryptedDownload(key)
+ AttachmentDownloadNotifier.emit(
+ AttachmentDownloadProgress.Failed(
+ key,
+ error.decryptFailureMessage(),
+ ),
+ messageLabel = messageLabel,
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+ return null
+ }
+
+ ensureAttachmentDownloadActive(key)
+
+ AttachmentDownloadNotifier.emit(
+ AttachmentDownloadProgress.InProgress(key, 99),
+ messageLabel = messageLabel,
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+ ApiClient.clearPartialEncryptedDownload(key)
+ val uri = cacheMutex.withLock {
+ resolveUriLocked(key) ?: commitCachePathLocked(key, outputPath)
+ }
+ if (uri == null) {
+ ApiClient.clearPartialEncryptedDownload(key)
+ AttachmentDownloadNotifier.emit(
+ AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
+ messageLabel = messageLabel,
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+ return null
+ }
+ DownloadedFileRegistry.setExportUri(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ exportUri = uri,
+ )
+ AttachmentDownloadNotifier.emit(
+ AttachmentDownloadProgress.Success(storageKey = key, messageId = messageId),
+ messageLabel = messageLabel,
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+ PendingFileSaveRegistry.onCacheReady(key)
+ return uri
+ }
+
+ private fun resolveUriLocked(storageKey: String): String? {
+ memoryCache[storageKey]?.let { uri ->
+ if (uriFileExists(uri)) return uri
+ memoryCache.remove(storageKey)
+ }
+ val fromDisk = readDisk(storageKey) ?: return null
+ memoryCache[storageKey] = fromDisk
+ return fromDisk
+ }
+
+ private fun uriFileExists(fileUri: String): Boolean {
+ val path = fileUri.removePrefix("file://")
+ return path.isNotEmpty() && PlatformFileSystem.exists(path)
+ }
+
+ private fun ensureCacheDir(): String? {
+ val base = PlatformFileSystem.getAppCacheDirectory()
+ if (base.isEmpty()) return null
+ val path = cacheDir?.takeIf { it.endsWith(SUBDIR) } ?: "$base/$SUBDIR"
+ return runCatching {
+ PlatformFileSystem.ensureDirectory(path)
+ if (!PlatformFileSystem.exists(path)) return null
+ cacheDir = path
+ path
+ }.getOrNull()
+ }
+
+ private fun diskPath(storageKey: String, displayFilename: String): String? {
+ val dir = ensureCacheDir() ?: return null
+ return "$dir/${diskBasename(storageKey, displayFilename)}"
+ }
+
+ private fun diskBasename(storageKey: String, displayFilename: String): String {
+ val safeName = sanitizeCacheFilename(displayFilename)
+ return "${storageKey}_$safeName"
+ }
+
+ private fun filenameFromDiskBasename(storageKey: String, basename: String): String? {
+ val prefix = "${storageKey}_"
+ if (!basename.startsWith(prefix)) return null
+ val rest = basename.removePrefix(prefix)
+ return rest.takeIf { it.isNotEmpty() }
+ }
+
+ private fun resolveDiskPath(storageKey: String): String? {
+ val dir = ensureCacheDir() ?: return null
+ val legacy = "$dir/$storageKey"
+ if (PlatformFileSystem.exists(legacy)) return legacy
+ val prefix = "${storageKey}_"
+ val match = PlatformFileSystem.listFileNamesInDirectory(dir)
+ .firstOrNull { it.startsWith(prefix) }
+ return match?.let { "$dir/$it" }
+ }
+
+ private fun readDisk(storageKey: String): String? {
+ val path = resolveDiskPath(storageKey) ?: return null
+ return "file://$path"
+ }
+
+ internal fun sanitizeCacheFilename(filename: String): String {
+ val base = filename.substringAfterLast('/').substringBefore('?').trim()
+ val cleaned = base.replace(Regex("[^a-zA-Z0-9._+-]"), "_")
+ return cleaned.take(180).ifEmpty { "attachment" }
+ }
+
+ private fun commitCachePathLocked(storageKey: String, path: String): String? {
+ if (!PlatformFileSystem.exists(path)) return null
+ if (PlatformFileSystem.fileSize(path) <= 0L) {
+ PlatformFileSystem.delete(path)
+ return null
+ }
+ val uri = "file://$path"
+ memoryCache[storageKey] = uri
+ return uri
+ }
+
+ private fun sanitizeKeyPart(value: String): String =
+ value.replace(Regex("[^a-zA-Z0-9._-]"), "_")
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt
index f0b2a0a..c701a12 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DecryptedImageCache.kt
@@ -1,8 +1,8 @@
package ru.fromchat.ui.chat
import com.pr0gramm3r101.utils.files.PlatformFileSystem
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -11,7 +11,7 @@ import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile
import ru.fromchat.api.AttachmentDownloadNotifier
import ru.fromchat.api.AttachmentDownloadProgress
-import ru.fromchat.crypto.decryptFile
+import ru.fromchat.crypto.decryptFileToPath
/**
* Disk + in-memory cache for decrypted DM images.
@@ -115,7 +115,7 @@ object DecryptedImageCache {
ru.fromchat.core.cache.readOutboundFileBytes(sourceUri)
}.getOrNull() ?: return
if (bytes.isEmpty()) return
- withContext(Dispatchers.Default + NonCancellable) {
+ withContext(Dispatchers.Default) {
cacheMutex.withLock {
if (readDisk(idKey) == null) {
writeCacheLocked(idKey, bytes)
@@ -167,47 +167,58 @@ object DecryptedImageCache {
cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
val label = AttachmentMediaLog.messageLabel(messageLabel)
- val uri = withContext(Dispatchers.Default + NonCancellable) {
+ AttachmentDownloadNotifier.beginDownload(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ )
+ val uri = withContext(Dispatchers.Default) {
runCatching {
- AttachmentDownloadScheduler.run(storageKey = key, messageId = messageId) {
+ AttachmentDownloadScheduler.run(
+ storageKey = key,
+ messageId = messageId,
+ work = {
+ AttachmentMediaLog.download(
+ "decrypt_start",
+ "key" to key,
+ "file" to file.path,
+ "msgId" to messageId,
+ "visible" to AttachmentDownloadVisibility.isPrioritized(messageId),
+ "msg" to label,
+ )
+ decryptAndPersist(
+ key = key,
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ file = file,
+ envelope = envelope,
+ currentUserId = currentUserId,
+ messageLabel = label,
+ )
+ },
+ )
+ }.onFailure { error ->
+ if (error !is CancellationException) {
+ ApiClient.clearPartialEncryptedDownload(key)
AttachmentMediaLog.download(
- "decrypt_start",
+ "decrypt_exception",
"key" to key,
- "file" to file.path,
"msgId" to messageId,
- "visible" to AttachmentDownloadVisibility.isPrioritized(messageId),
"msg" to label,
+ "err" to (error.message ?: error::class.simpleName),
)
- decryptAndPersist(
- key = key,
+ AttachmentDownloadNotifier.emit(
+ AttachmentDownloadProgress.Failed(
+ storageKey = key,
+ error = error.message ?: "decrypt_failed",
+ ),
+ messageLabel = label,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
- file = file,
- envelope = envelope,
- currentUserId = currentUserId,
- messageLabel = label,
)
}
- }.onFailure { error ->
- ApiClient.clearPartialEncryptedDownload(key)
- AttachmentMediaLog.download(
- "decrypt_exception",
- "key" to key,
- "msgId" to messageId,
- "msg" to label,
- "err" to (error.message ?: error::class.simpleName),
- )
- AttachmentDownloadNotifier.emit(
- AttachmentDownloadProgress.Failed(
- storageKey = key,
- error = error.message ?: "decrypt_failed",
- ),
- messageLabel = label,
- messageId = messageId,
- fileIndex = fileIndex,
- clientMessageId = clientMessageId,
- )
}.getOrNull()
}
if (uri != null && messageId > 0) {
@@ -263,7 +274,7 @@ object DecryptedImageCache {
fileIndex: Int,
localFileUri: String,
clientMessageId: String? = null,
- ): String? = withContext(Dispatchers.Default + NonCancellable) {
+ ): String? = withContext(Dispatchers.Default) {
val key = storageKey(messageId, fileIndex, clientMessageId)
cacheMutex.withLock { resolveUriLocked(key) }?.let { existing ->
AttachmentMediaLog.diskCache(
@@ -304,6 +315,7 @@ object DecryptedImageCache {
currentUserId: Int?,
messageLabel: String? = null,
): String? {
+ ensureAttachmentDownloadActive(key)
cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.InProgress(key, 1),
@@ -313,13 +325,27 @@ object DecryptedImageCache {
clientMessageId = clientMessageId,
)
val t0 = AttachmentMediaLog.nowMs()
- val bytes = runCatching {
- decryptFile(
+ val outputPath = diskPath(key)
+ if (outputPath == null) {
+ ApiClient.clearPartialEncryptedDownload(key)
+ AttachmentDownloadNotifier.emit(
+ AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
+ messageLabel = messageLabel,
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ )
+ return null
+ }
+ val decryptedSize = runCatching {
+ decryptFileToPath(
file = file,
envelope = envelope,
currentUserId = currentUserId,
+ outputPath = outputPath,
downloadResumeKey = key,
onDownloadProgress = { percent ->
+ checkAttachmentDownloadActive(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.InProgress(key, percent.coerceIn(0, 100)),
messageLabel = messageLabel,
@@ -330,6 +356,9 @@ object DecryptedImageCache {
},
)
}.onFailure { error ->
+ if (error is CancellationException) {
+ throw error
+ }
ApiClient.clearPartialEncryptedDownload(key)
AttachmentMediaLog.download(
"decrypt_failed",
@@ -347,10 +376,11 @@ object DecryptedImageCache {
clientMessageId = clientMessageId,
)
}.getOrNull()
- if (bytes == null) {
+ if (decryptedSize == null) {
ApiClient.clearPartialEncryptedDownload(key)
return null
}
+ ensureAttachmentDownloadActive(key)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.InProgress(key, 99),
messageLabel = messageLabel,
@@ -358,8 +388,9 @@ object DecryptedImageCache {
fileIndex = fileIndex,
clientMessageId = clientMessageId,
)
+ ApiClient.clearPartialEncryptedDownload(key)
val uri = cacheMutex.withLock {
- resolveUriLocked(key) ?: writeCacheLocked(key, bytes)
+ resolveUriLocked(key) ?: commitCachePathLocked(key, outputPath)
}
if (uri == null) {
ApiClient.clearPartialEncryptedDownload(key)
@@ -367,7 +398,7 @@ object DecryptedImageCache {
"decrypt_persist_failed",
"key" to key,
"msg" to messageLabel,
- "bytes" to bytes.size,
+ "bytes" to decryptedSize,
)
AttachmentDownloadNotifier.emit(
AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
@@ -381,7 +412,7 @@ object DecryptedImageCache {
AttachmentMediaLog.download(
"decrypt_persist_ok",
"key" to key,
- "bytes" to bytes.size,
+ "bytes" to decryptedSize,
"ms" to (AttachmentMediaLog.nowMs() - t0),
"uri" to uri,
"msg" to messageLabel,
@@ -453,6 +484,13 @@ object DecryptedImageCache {
}
}
+ private fun commitCachePathLocked(storageKey: String, path: String): String? {
+ if (!PlatformFileSystem.exists(path)) return null
+ val uri = "file://$path"
+ memoryCache[storageKey] = uri
+ return uri
+ }
+
private fun invalidatePath(path: String) {
runCatching { PlatformFileSystem.delete(path) }
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.kt
index aedf6f5..3f23ee0 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.kt
@@ -1,112 +1,34 @@
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(
+ suspend fun downloadToCache(
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)
- }
+ ): Boolean = withContext(Dispatchers.Default) {
+ DecryptedFileCache.getOrDecrypt(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ file = file,
+ envelope = envelope,
+ currentUserId = currentUserId,
+ clientMessageId = clientMessageId,
+ messageLabel = messageLabel,
+ ) != null
}
}
-expect suspend fun writeBytesToExportUri(exportUri: String, bytes: ByteArray): Boolean
-
-expect suspend fun isExportUriAccessible(exportUri: String): Boolean
-
-expect fun openExportUri(exportUri: String, mimeType: String): Boolean
+expect suspend fun openCachedAttachmentFile(
+ cacheUri: String,
+ mimeType: String,
+ displayFilename: String? = null,
+): Boolean
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DownloadedFileRegistry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DownloadedFileRegistry.kt
index 7e5a7ce..d95c6f2 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DownloadedFileRegistry.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/DownloadedFileRegistry.kt
@@ -21,6 +21,21 @@ object DownloadedFileRegistry {
private val memory = mutableMapOf()
private var diskIndexLoaded = false
+ fun messageIdFromStorageKey(storageKey: String): Int? {
+ if (!storageKey.startsWith("file_") || storageKey.startsWith("file_c_")) return null
+ return storageKey.removePrefix("file_").substringBefore('_').toIntOrNull()
+ }
+
+ fun fileIndexFromStorageKey(storageKey: String): Int? {
+ if (storageKey.startsWith("file_c_")) {
+ return storageKey.substringAfterLast('_').toIntOrNull()
+ }
+ if (storageKey.startsWith("file_")) {
+ return storageKey.substringAfterLast('_').toIntOrNull()
+ }
+ return null
+ }
+
fun storageKey(
messageId: Int,
fileIndex: Int,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/FileAttachmentLeadingSlot.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/FileAttachmentLeadingSlot.kt
new file mode 100644
index 0000000..4d0c8f2
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/FileAttachmentLeadingSlot.kt
@@ -0,0 +1,136 @@
+package ru.fromchat.ui.chat
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+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.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+private enum class FileLeadingVisual {
+ Download,
+ Progress,
+ File,
+}
+
+internal const val AttachmentLeadingTransitionMs = 260
+
+private fun leadingTransitionSpec() =
+ scaleIn(
+ initialScale = 0.82f,
+ animationSpec = tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing),
+ ) + fadeIn(tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing)) togetherWith
+ scaleOut(
+ targetScale = 0.82f,
+ animationSpec = tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing),
+ ) + fadeOut(tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing))
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+internal fun FileAttachmentLeadingSlot(
+ isProgressing: Boolean,
+ isDownloaded: Boolean,
+ uploadProgress: Int?,
+ isAuthor: Boolean,
+ onCancelProgress: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val visual = when {
+ isProgressing -> FileLeadingVisual.Progress
+ isDownloaded -> FileLeadingVisual.File
+ else -> FileLeadingVisual.Download
+ }
+ val containerColor = if (isAuthor) {
+ Color.White.copy(alpha = 0.22f)
+ } else {
+ MaterialTheme.colorScheme.secondaryContainer
+ }
+ val iconOnContainer = if (isAuthor) {
+ Color.White
+ } else {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ }
+
+ AnimatedContent(
+ targetState = visual,
+ modifier = modifier,
+ transitionSpec = { leadingTransitionSpec() },
+ label = "fileLeadingIcon",
+ ) { target ->
+ when (target) {
+ FileLeadingVisual.Progress -> {
+ CancellableAttachmentProgressIndicator(
+ progress = uploadProgress,
+ onCancel = onCancelProgress,
+ showCloseScrim = false,
+ modifier = Modifier.fillMaxSize(),
+ indicatorColor = if (isAuthor) Color.White else null,
+ trackColorOverride = if (isAuthor) {
+ Color.White.copy(alpha = 0.28f)
+ } else {
+ null
+ },
+ )
+ }
+ FileLeadingVisual.File -> {
+ LeadingIconCircle(
+ icon = Icons.Rounded.InsertDriveFile,
+ containerColor = containerColor,
+ iconTint = iconOnContainer,
+ )
+ }
+ FileLeadingVisual.Download -> {
+ LeadingIconCircle(
+ icon = Icons.Rounded.Download,
+ containerColor = containerColor,
+ iconTint = iconOnContainer,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun LeadingIconCircle(
+ icon: ImageVector,
+ containerColor: Color,
+ iconTint: Color,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ shape = CircleShape,
+ color = containerColor,
+ modifier = modifier.fillMaxSize(),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(26.dp),
+ tint = iconTint,
+ )
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt
index 790b6e8..313c676 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt
@@ -326,6 +326,8 @@ private fun ContextMenuContent(
val labelCancelSend = stringResource(Res.string.action_cancel_send)
val isQueued = message.isQueuedOutbound() && isAuthor
val savableImage = resolveSavableMessageImage(message)
+ val savableFile = resolveSavableMessageFile(message)
+ val canSave = savableImage != null || savableFile != null
Box(modifier = containerModifier) {
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape))
@@ -342,7 +344,7 @@ private fun ContextMenuContent(
onClick = { onCopy(message) }
)
}
- if (savableImage != null) {
+ if (canSave) {
ContextMenuItem(
icon = Icons.Rounded.SaveAlt,
text = labelSave,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.kt
new file mode 100644
index 0000000..ad4f9fd
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.kt
@@ -0,0 +1,102 @@
+package ru.fromchat.ui.chat
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import com.pr0gramm3r101.utils.files.PlatformFileSystem
+import kotlinx.coroutines.launch
+import ru.fromchat.api.AttachmentDownloadNotifier
+import ru.fromchat.api.Message
+
+data class SavableMessageFile(
+ val fileIndex: Int,
+ val cacheUri: String,
+ val filename: String,
+ val mimeType: String,
+ val storageKey: String,
+ val messageId: Int,
+ val clientMessageId: String?,
+)
+
+fun isMessageFileCached(message: Message, fileIndex: Int): Boolean {
+ val file = message.files?.getOrNull(fileIndex) ?: return false
+ if (isImageFilename(file.name)) return false
+ return DecryptedFileCache.getCached(
+ messageId = message.id,
+ fileIndex = fileIndex,
+ clientMessageId = message.client_message_id,
+ ) != null
+}
+
+fun cachedAttachmentFileSize(cacheUri: String): Long {
+ val path = cacheUri.removePrefix("file://")
+ if (path.isEmpty() || !PlatformFileSystem.exists(path)) return 0L
+ return PlatformFileSystem.fileSize(path)
+}
+
+fun resolveSavableMessageFile(message: Message): SavableMessageFile? {
+ message.files?.forEachIndexed { index, file ->
+ if (isImageFilename(file.name)) return@forEachIndexed
+ if (message.dmEnvelope == null) return@forEachIndexed
+ val cacheUri = DecryptedFileCache.getCached(
+ messageId = message.id,
+ fileIndex = index,
+ clientMessageId = message.client_message_id,
+ ) ?: return@forEachIndexed
+ if (cachedAttachmentFileSize(cacheUri) <= 0L) return@forEachIndexed
+ val storageKey = DownloadedFileRegistry.storageKey(
+ messageId = message.id,
+ fileIndex = index,
+ clientMessageId = message.client_message_id,
+ )
+ return SavableMessageFile(
+ fileIndex = index,
+ cacheUri = cacheUri,
+ filename = file.name,
+ mimeType = mimeTypeForFilename(file.name),
+ storageKey = storageKey,
+ messageId = message.id,
+ clientMessageId = message.client_message_id,
+ )
+ }
+ return null
+}
+
+suspend fun ensureFileDownloadedForSave(
+ message: Message,
+ savable: SavableMessageFile,
+) {
+ if (isMessageFileCached(message, savable.fileIndex)) return
+ val file = message.files?.getOrNull(savable.fileIndex) ?: return
+ val envelope = message.dmEnvelope ?: return
+ AttachmentDownloadNotifier.beginDownload(
+ messageId = message.id,
+ fileIndex = savable.fileIndex,
+ clientMessageId = message.client_message_id,
+ mirrorAsFileAttachment = true,
+ )
+ DmFileDownloader.downloadToCache(
+ messageId = message.id,
+ fileIndex = savable.fileIndex,
+ file = file,
+ envelope = envelope,
+ currentUserId = null,
+ clientMessageId = message.client_message_id,
+ )
+}
+
+@Composable
+fun rememberSaveMessageFile(onComplete: (Boolean) -> Unit): (SavableMessageFile) -> Unit {
+ val scope = rememberCoroutineScope()
+ val platformLaunch = rememberPlatformSaveMessageFile(onComplete)
+ return remember(platformLaunch, scope) {
+ { savable: SavableMessageFile ->
+ platformLaunch(savable)
+ }
+ }
+}
+
+@Composable
+expect fun rememberPlatformSaveMessageFile(
+ onComplete: (Boolean) -> Unit,
+): (SavableMessageFile) -> Unit
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
index d264db9..0ab0221 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
@@ -54,9 +54,23 @@ import com.pr0gramm3r101.utils.conditional
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.Message
+import ru.fromchat.api.isQueuedOutbound
import ru.fromchat.api.formatMessageTimeLocal
import ru.fromchat.*
+/** True when [Message.content] is only a filename placeholder (no real caption). */
+internal fun isFilenameOnlyMessageCaption(message: Message): Boolean {
+ val content = message.content.trim()
+ if (content.isEmpty()) return false
+ message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() }?.let { pending ->
+ if (content == pending) return true
+ }
+ message.files.orEmpty().forEach { file ->
+ if (content == file.name.trim()) return true
+ }
+ return false
+}
+
private fun isMessageCorrupted(message: Message): Boolean {
val files = message.files ?: return false
return files.withIndex().any { (index, file) ->
@@ -85,7 +99,9 @@ fun MessageItem(
isContextMenuForThisMessage: Boolean = false,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
- sharedAvatarNavKey: String? = null
+ sharedAvatarNavKey: String? = null,
+ onCancelOutboundAttachment: ((Message) -> Unit)? = null,
+ onRetryOutboundAttachment: ((Message) -> Unit)? = null,
) {
// Cache derived values per message to avoid recomputing in every recomposition.
val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
@@ -205,6 +221,21 @@ fun MessageItem(
val pendingHasOutboundFile = message.pendingFileUri != null &&
message.files.isNullOrEmpty() &&
!pendingIsImage
+ val uploadFailed = !message.uploadError.isNullOrBlank()
+ val canCancelUpload = message.isQueuedOutbound() && isAuthor &&
+ !uploadFailed &&
+ (pendingIsImage || pendingHasOutboundFile) &&
+ (message.uploadProgress != null || message.pendingFileUri != null)
+ val onCancelUpload: (() -> Unit)? = if (canCancelUpload && onCancelOutboundAttachment != null) {
+ { onCancelOutboundAttachment.invoke(message) }
+ } else {
+ null
+ }
+ val onRetryUpload: (() -> Unit)? = if (uploadFailed && onRetryOutboundAttachment != null) {
+ { onRetryOutboundAttachment.invoke(message) }
+ } else {
+ null
+ }
val firstContentIsImage = (
!showUsername || isAuthor
) && message.reply_to == null && (
@@ -428,6 +459,7 @@ fun MessageItem(
val awaitingServer = message.id < 0 && message.files.isNullOrEmpty()
val isOutboundPendingImage = awaitingServer && pendingIsImage
val awaitingServerAck = isOutboundPendingImage &&
+ !uploadFailed &&
message.uploadProgress == null
AttachmentPreview(
file = primaryFile,
@@ -435,9 +467,11 @@ fun MessageItem(
currentUserId = currentUserId,
pendingFileUri = message.pendingFileUri,
pendingFilename = message.pendingFilename,
- isUploading = isOutboundPendingImage,
+ isUploading = isOutboundPendingImage && !uploadFailed,
awaitingServerAck = awaitingServerAck,
uploadProgress = message.uploadProgress,
+ uploadError = message.uploadError,
+ onRetryUpload = onRetryUpload,
fileThumbnail = message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() },
fileAspectRatio = imageAspectRatioForMessage(
fileAspectRatios = message.fileAspectRatios,
@@ -464,6 +498,7 @@ fun MessageItem(
!isImageClosing,
isAuthor = isAuthor,
messageLabel = message.content,
+ onCancelUpload = onCancelUpload,
modifier = if (firstContentIsImage) {
Modifier.padding(all = 2.dp)
} else {
@@ -475,6 +510,7 @@ fun MessageItem(
val awaitingServer = message.id < 0 && message.files.isNullOrEmpty()
val isOutboundPendingFile = awaitingServer && pendingHasOutboundFile
val awaitingServerAck = isOutboundPendingFile &&
+ !uploadFailed &&
message.uploadProgress == null
AttachmentPreview(
file = primaryFile,
@@ -482,16 +518,19 @@ fun MessageItem(
currentUserId = currentUserId,
pendingFileUri = message.pendingFileUri,
pendingFilename = message.pendingFilename,
- isUploading = isOutboundPendingFile,
+ isUploading = isOutboundPendingFile && !uploadFailed,
awaitingServerAck = awaitingServerAck,
uploadProgress = message.uploadProgress,
+ uploadError = message.uploadError,
+ onRetryUpload = onRetryUpload,
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),
+ onCancelUpload = onCancelUpload,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
)
}
message.files?.forEachIndexed { index, file ->
@@ -532,22 +571,23 @@ fun MessageItem(
isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing,
isAuthor = isAuthor,
messageLabel = message.content,
+ onCancelUpload = onCancelUpload,
modifier = if (isFirstImage && firstContentIsImage && isImage) {
Modifier.padding(all = 2.dp)
} else {
Modifier.padding(
- horizontal = if (isImage) 2.dp else 12.dp,
+ horizontal = if (isImage) 2.dp else 4.dp,
vertical = if (isImage) 2.dp else 4.dp
)
}
)
}
}
- val hideFilenamePlaceholderCaption = message.pendingFileUri != null &&
- message.files.isNullOrEmpty() &&
- message.pendingFilename != null &&
- message.content == message.pendingFilename
- if (message.content.isNotBlank() && !isCorrupted && !hideFilenamePlaceholderCaption) {
+ if (
+ message.content.isNotBlank() &&
+ !isCorrupted &&
+ !isFilenameOnlyMessageCaption(message)
+ ) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt
index d4280b6..33cbd79 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt
@@ -59,6 +59,7 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio
},
uploadProgress = if (confirmed) null else panel.uploadProgress ?: db.uploadProgress,
+ uploadError = if (confirmed) null else panel.uploadError ?: db.uploadError,
files = db.files ?: panel.files,
dmEnvelope = db.dmEnvelope ?: panel.dmEnvelope,
fileThumbnails = db.fileThumbnails ?: panel.fileThumbnails,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileCacheBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileCacheBootstrap.kt
new file mode 100644
index 0000000..175bbe3
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileCacheBootstrap.kt
@@ -0,0 +1,39 @@
+package ru.fromchat.ui.chat
+
+import ru.fromchat.api.AttachmentDownloadNotifier
+
+/**
+ * Copies a staged outbound file into the cache under [displayFilename] (correct extension on disk).
+ */
+suspend fun seedOutboundFileAsDownloaded(
+ messageId: Int,
+ fileIndex: Int,
+ localFileUri: String,
+ displayFilename: String,
+ clientMessageId: String?,
+) {
+ val cacheUri = DecryptedFileCache.seedFromLocalFile(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ localFileUri = localFileUri,
+ displayFilename = displayFilename,
+ clientMessageId = clientMessageId,
+ ) ?: return
+ DownloadedFileRegistry.setExportUri(
+ messageId = messageId,
+ fileIndex = fileIndex,
+ clientMessageId = clientMessageId,
+ exportUri = cacheUri,
+ )
+}
+
+suspend fun clearOutboundFileCaches(clientMessageId: String, optimisticMessageId: Int) {
+ DecryptedFileCache.invalidateForClientMessage(clientMessageId)
+ DownloadedFileRegistry.invalidateForClientMessage(clientMessageId)
+ AttachmentDownloadNotifier.clearProgress(
+ messageId = optimisticMessageId,
+ fileIndex = 0,
+ clientMessageId = clientMessageId,
+ mirrorAsFileAttachment = true,
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileStaging.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileStaging.kt
index 80e29ec..b236d93 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileStaging.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundFileStaging.kt
@@ -6,16 +6,34 @@ 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).
+ * Copy a non-image attachment into instance upload storage before any upload work runs.
+ * Seeds decrypted-file cache under the real filename (extension preserved for open/install).
*/
suspend fun prepareOutboundFileForSend(
clientMessageId: String,
sourceUri: String,
+ optimisticMessageId: Int,
+ displayFilename: 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)
+
+ val cacheUri = DecryptedFileCache.seedFromLocalFile(
+ messageId = optimisticMessageId,
+ fileIndex = 0,
+ localFileUri = staged.uri,
+ displayFilename = displayFilename,
+ clientMessageId = clientMessageId,
+ ) ?: return@withContext null
+ DownloadedFileRegistry.setExportUri(
+ messageId = optimisticMessageId,
+ fileIndex = 0,
+ clientMessageId = clientMessageId,
+ exportUri = cacheUri,
+ )
+
+ StagedOutboundPreview(stagedUri = staged.uri, aspectRatio = null, sizeBytes = staged.sizeBytes)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundImageStaging.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundImageStaging.kt
index 3ab8125..37c8183 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundImageStaging.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundImageStaging.kt
@@ -8,6 +8,7 @@ import ru.fromchat.core.cache.stageOutboundFileForUpload
data class StagedOutboundPreview(
val stagedUri: String,
val aspectRatio: Float?,
+ val sizeBytes: Long = 0L,
)
/**
@@ -37,7 +38,7 @@ suspend fun prepareOutboundImageForSend(
val decodeTarget = previewSeedDecodeSize(aspectRatio)
LocalDecodedImageCache.loadFull(storageKey, staged.uri, decodeTarget)
- StagedOutboundPreview(stagedUri = staged.uri, aspectRatio = aspectRatio)
+ StagedOutboundPreview(stagedUri = staged.uri, aspectRatio = aspectRatio, sizeBytes = staged.sizeBytes)
}
/** High-quality seed decode before the tile is measured (refined when laid out). */
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.kt
new file mode 100644
index 0000000..3a44ce0
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.kt
@@ -0,0 +1,114 @@
+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.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import ru.fromchat.core.cache.CacheContext
+import ru.fromchat.core.cache.readOutboundFileBytes
+
+@Serializable
+data class PendingFileSaveEntry(
+ val storageKey: String,
+ val destinationUri: String,
+ val filename: String,
+ val mimeType: String,
+ val clientMessageId: String? = null,
+)
+
+/**
+ * Outbox for "save attachment to user folder" — survives process death until copy completes.
+ */
+object PendingFileSaveRegistry {
+ private const val INDEX_FILE = "pending_file_saves.json"
+ private val json = Json { ignoreUnknownKeys = true }
+ private val mutex = Mutex()
+ private val memory = mutableListOf()
+ private var diskLoaded = false
+
+ suspend fun schedule(entry: PendingFileSaveEntry) {
+ ensureLoaded()
+ mutex.withLock {
+ memory.removeAll { it.storageKey == entry.storageKey }
+ memory.add(entry)
+ persistLocked()
+ }
+ enqueuePlatformCopy(entry.storageKey)
+ }
+
+ suspend fun remove(storageKey: String) {
+ ensureLoaded()
+ mutex.withLock {
+ if (memory.removeAll { it.storageKey == storageKey }) {
+ persistLocked()
+ }
+ }
+ }
+
+ suspend fun listPending(): List {
+ ensureLoaded()
+ return mutex.withLock { memory.toList() }
+ }
+
+ suspend fun onCacheReady(storageKey: String) {
+ ensureLoaded()
+ val hasPending = mutex.withLock {
+ memory.any { it.storageKey == storageKey }
+ }
+ if (hasPending) {
+ enqueuePlatformCopy(storageKey)
+ }
+ }
+
+ private suspend fun ensureLoaded() {
+ if (diskLoaded) return
+ val fromDisk = withContext(Dispatchers.Default) { readIndexFromDisk() }
+ mutex.withLock {
+ if (!diskLoaded) {
+ memory.clear()
+ memory.addAll(fromDisk)
+ diskLoaded = true
+ }
+ }
+ memory.forEach { entry ->
+ val cacheUri = DecryptedFileCache.getCachedUriForStorageKey(entry.storageKey)
+ if (cacheUri != null && cachedAttachmentFileSize(cacheUri) > 0L) {
+ enqueuePlatformCopy(entry.storageKey)
+ }
+ }
+ }
+
+ 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(): List {
+ val path = indexPath() ?: return emptyList()
+ if (!PlatformFileSystem.exists(path)) return emptyList()
+ val bytes = runCatching {
+ readOutboundFileBytes("file://$path")
+ }.getOrNull() ?: return emptyList()
+ if (bytes.isEmpty()) return emptyList()
+ return runCatching {
+ json.decodeFromString>(bytes.decodeToString())
+ }.getOrDefault(emptyList())
+ }
+
+ private fun persistLocked() {
+ val path = indexPath() ?: return
+ val bytes = json.encodeToString(memory).encodeToByteArray()
+ PlatformFileSystem.writeBytes(path, bytes)
+ }
+}
+
+internal expect fun enqueuePlatformCopy(storageKey: String)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt
index 82b80d9..230f9cb 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt
@@ -60,6 +60,6 @@ fun PublicChatScreen(
scrollToMessageId = scrollToMessageId,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedContentScope,
- sharedAvatarKey = "public-general-chat"
+ sharedAvatarKey = null,
)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt
index e7ef448..ea46791 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt
@@ -37,7 +37,10 @@ import ru.fromchat.crypto.DmCiphertextCorruptedException
import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.ui.chat.AvatarInfo
import ru.fromchat.ui.chat.ChatPanel
+import ru.fromchat.ui.chat.DecryptedFileCache
import ru.fromchat.ui.chat.DecryptedImageCache
+import ru.fromchat.ui.chat.isImageFilename
+import ru.fromchat.ui.chat.seedOutboundFileAsDownloaded
import ru.fromchat.ui.chat.DownloadedFileRegistry
import ru.fromchat.ui.chat.DmTypingHandler
import ru.fromchat.ui.chat.TypingHandler
@@ -346,6 +349,19 @@ class DmPanel(
fileIndex = 0,
clientMessageId = cid,
)
+ } else if (!isImageAttachment && localUri != null && dmFile != null) {
+ seedOutboundFileAsDownloaded(
+ messageId = envelope.id,
+ fileIndex = 0,
+ localFileUri = localUri,
+ displayFilename = dmFile.name,
+ clientMessageId = cid,
+ )
+ DecryptedFileCache.ensureDiskAliasForMessageId(
+ messageId = envelope.id,
+ fileIndex = 0,
+ clientMessageId = cid,
+ )
}
val localPreviewUri = resolveLocalPreviewUri(
confirmed.copy(
diff --git a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq
index 6e621f6..b040c24 100644
--- a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq
+++ b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq
@@ -130,6 +130,17 @@ FROM message
WHERE instanceId = ? AND conversationId = ? AND id = ? AND deletedFlag = 0
LIMIT 1;
+selectMessageByNumericId:
+SELECT *
+FROM message
+WHERE instanceId = ? AND id = ? AND deletedFlag = 0
+LIMIT 1;
+
+selectMessagesForInstance:
+SELECT *
+FROM message
+WHERE instanceId = ? AND deletedFlag = 0;
+
deleteMessagesForConversation:
DELETE FROM message
WHERE instanceId = ? AND conversationId = ?;
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/IosApplicationBootstrap.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/IosApplicationBootstrap.kt
new file mode 100644
index 0000000..c564ebb
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/IosApplicationBootstrap.kt
@@ -0,0 +1,25 @@
+package ru.fromchat
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import ru.fromchat.api.ApiClient
+import ru.fromchat.api.AttachmentTransferBootstrap
+
+/**
+ * iOS cold start (call from [iOSApp] Swift `init`, not from UIViewController lifecycle).
+ */
+object IosApplicationBootstrap {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private var started = false
+
+ fun launchOnApplicationStart() {
+ if (started) return
+ started = true
+ scope.launch {
+ runCatching { ApiClient.loadPersistedData() }
+ runCatching { AttachmentTransferBootstrap.runColdStart() }
+ }
+ }
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.ios.kt
new file mode 100644
index 0000000..a0a3980
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentDownloadForeground.ios.kt
@@ -0,0 +1,9 @@
+package ru.fromchat.api
+
+actual object AttachmentDownloadForeground {
+ actual fun onFileDownloadStarted(storageKey: String) = Unit
+
+ actual fun onFileDownloadProgress(percent: Int, displayLabel: String?) = Unit
+
+ actual fun onFileDownloadFinished(storageKey: String) = Unit
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.ios.kt
new file mode 100644
index 0000000..3fd284c
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentFileCopyForeground.ios.kt
@@ -0,0 +1,6 @@
+package ru.fromchat.api
+
+actual object AttachmentFileCopyForeground {
+ actual fun onCopyStarted(storageKey: String, displayLabel: String?) = Unit
+ actual fun onCopyFinished(storageKey: String) = Unit
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.ios.kt
new file mode 100644
index 0000000..ed9bbb7
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/EncryptedFileDownloader.ios.kt
@@ -0,0 +1,15 @@
+package ru.fromchat.api
+
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.darwin.Darwin
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.HttpTimeoutConfig
+
+internal actual fun encryptedDownloadHttpClient(): HttpClient =
+ HttpClient(Darwin) {
+ install(HttpTimeout) {
+ connectTimeoutMillis = 15_000
+ requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
+ socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
+ }
+ }
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.ios.kt
new file mode 100644
index 0000000..c034229
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/core/DeferredStartupNetwork.ios.kt
@@ -0,0 +1,5 @@
+package ru.fromchat.core
+
+actual suspend fun syncPushTokenAfterStartup() {
+ // iOS push registration is handled separately when APNs is wired.
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.ios.kt
new file mode 100644
index 0000000..f86de20
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/AttachmentUploadLimits.ios.kt
@@ -0,0 +1,5 @@
+package ru.fromchat.core.cache
+
+private const val IOS_MAX_BYTES = 32L * 1024L * 1024L
+
+actual fun maxInMemoryEncryptPlaintextBytes(): Long = IOS_MAX_BYTES
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.ios.kt
index 4a80479..1a3fd27 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.ios.kt
@@ -12,11 +12,17 @@ import kotlinx.coroutines.withContext
import platform.Foundation.NSCachesDirectory
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
+import platform.Foundation.NSNumber
import platform.Foundation.NSURL
+import platform.Foundation.NSURLFileSizeKey
import platform.Foundation.NSUserDomainMask
import platform.Foundation.create
import platform.Foundation.dataWithContentsOfURL
import platform.Foundation.writeToFile
+import ru.fromchat.platform.IosPosixFileReader
+import ru.fromchat.platform.iosCopyFile
+import ru.fromchat.platform.iosFileSize
+import ru.fromchat.platform.iosReadFileRange
@OptIn(ExperimentalForeignApi::class)
private fun uploadDir(instanceId: String): String {
@@ -33,23 +39,218 @@ private fun uploadDir(instanceId: String): String {
return path
}
+private fun safeId(clientMessageId: String): String =
+ clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
+
+private fun sourcePath(instanceId: String, clientMessageId: String): String =
+ "${uploadDir(instanceId)}/${safeId(clientMessageId)}.source"
+
+private fun sourcePartPath(instanceId: String, clientMessageId: String): String =
+ "${uploadDir(instanceId)}/${safeId(clientMessageId)}.source.part"
+
+private fun sourceOkPath(instanceId: String, clientMessageId: String): String =
+ "${uploadDir(instanceId)}/${safeId(clientMessageId)}.source.ok"
+
@OptIn(ExperimentalForeignApi::class)
-private fun blobPath(instanceId: String, clientMessageId: String): String {
- val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
- return "${uploadDir(instanceId)}/$safeId.enc"
+private fun blobPath(instanceId: String, clientMessageId: String): String =
+ "${uploadDir(instanceId)}/${safeId(clientMessageId)}.enc"
+
+@OptIn(ExperimentalForeignApi::class)
+private fun blobPartPath(instanceId: String, clientMessageId: String): String =
+ "${uploadDir(instanceId)}/${safeId(clientMessageId)}.enc.part"
+
+@OptIn(ExperimentalForeignApi::class)
+private fun blobOkPath(instanceId: String, clientMessageId: String): String =
+ "${uploadDir(instanceId)}/${safeId(clientMessageId)}.enc.ok"
+
+@OptIn(ExperimentalForeignApi::class)
+private fun cipherPath(instanceId: String, clientMessageId: String): String =
+ "${uploadDir(instanceId)}/${safeId(clientMessageId)}.cipher.json"
+
+@OptIn(ExperimentalForeignApi::class)
+private fun cipherPartPath(instanceId: String, clientMessageId: String): String =
+ "${uploadDir(instanceId)}/${safeId(clientMessageId)}.cipher.json.part"
+
+actual fun encryptedUploadBlobPath(instanceId: String, clientMessageId: String): String =
+ blobPath(instanceId, clientMessageId)
+
+actual fun encryptedUploadBlobPartPath(instanceId: String, clientMessageId: String): String =
+ blobPartPath(instanceId, clientMessageId)
+
+@OptIn(ExperimentalForeignApi::class)
+private fun readOkMarker(okPath: String, diskPath: String, expectedBytes: Long): Boolean {
+ if (!NSFileManager.defaultManager.fileExistsAtPath(okPath)) return false
+ if (!NSFileManager.defaultManager.fileExistsAtPath(diskPath)) return false
+ val raw = NSData.create(contentsOfFile = okPath)?.let { data ->
+ val ptr = data.bytes?.reinterpret() ?: return false
+ ByteArray(data.length.toInt()) { i -> ptr[i] }.decodeToString()
+ } ?: return false
+ val marker = decodeUploadArtifactOkMarker(raw) ?: return false
+ return marker.isValidOnDisk(iosFileSize(diskPath), expectedBytes)
+}
+
+@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
+private fun writeOkMarker(okPath: String, actualBytes: Long, expectedBytes: Long) {
+ val text = encodeUploadArtifactOkMarker(actualBytes, expectedBytes)
+ text.encodeToByteArray().usePinned { pinned ->
+ val data = NSData.create(bytes = pinned.addressOf(0), length = text.length.toULong())
+ data?.writeToFile(okPath, true)
+ }
}
@OptIn(ExperimentalForeignApi::class)
-private fun cipherPath(instanceId: String, clientMessageId: String): String {
- val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
- return "${uploadDir(instanceId)}/$safeId.cipher.json"
+private fun atomicReplace(partPath: String, finalPath: String) {
+ if (!NSFileManager.defaultManager.fileExistsAtPath(partPath)) {
+ error("Partial upload file missing")
+ }
+ NSFileManager.defaultManager.removeItemAtPath(finalPath, null)
+ if (!NSFileManager.defaultManager.moveItemAtPath(partPath, toPath = finalPath, error = null)) {
+ iosCopyFile(partPath, finalPath)
+ NSFileManager.defaultManager.removeItemAtPath(partPath, null)
+ }
}
-private fun sourcePath(instanceId: String, clientMessageId: String): String {
- val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
- return "${uploadDir(instanceId)}/$safeId.source"
+@OptIn(ExperimentalForeignApi::class)
+actual suspend fun queryOutboundUriSizeBytes(fileUri: String): Long? = withContext(Dispatchers.Default) {
+ val url = NSURL.URLWithString(fileUri) ?: return@withContext null
+ val values = url.resourceValuesForKeys(listOf(NSURLFileSizeKey), null)
+ (values?.get(NSURLFileSizeKey) as? NSNumber)?.longValue
}
+@OptIn(ExperimentalForeignApi::class)
+actual suspend fun stageOutboundFileForUpload(
+ instanceId: String,
+ clientMessageId: String,
+ sourceUri: String,
+ expectedSizeBytes: Long,
+): StagedOutboundFile = withContext(Dispatchers.Default) {
+ repairInterruptedUploadArtifacts(instanceId, clientMessageId)
+ val dest = sourcePath(instanceId, clientMessageId)
+ if (sourceUri == dest) {
+ if (!isStagedSourceReady(instanceId, clientMessageId, expectedSizeBytes)) {
+ throw OutboundFileUnavailableException("Staged source file is incomplete")
+ }
+ return@withContext StagedOutboundFile(uri = dest, sizeBytes = iosFileSize(dest))
+ }
+ if (isStagedSourceReady(instanceId, clientMessageId, expectedSizeBytes)) {
+ return@withContext StagedOutboundFile(uri = dest, sizeBytes = iosFileSize(dest))
+ }
+ val part = sourcePartPath(instanceId, clientMessageId)
+ val sourcePathOnDisk = when {
+ sourceUri.startsWith("file://") -> NSURL.URLWithString(sourceUri)?.path
+ else -> sourceUri
+ } ?: throw OutboundFileUnavailableException("Invalid file URI")
+ NSFileManager.defaultManager.removeItemAtPath(dest, null)
+ NSFileManager.defaultManager.removeItemAtPath(sourceOkPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(part, null)
+ iosCopyFile(sourcePathOnDisk, part)
+ atomicReplace(part, dest)
+ val stagedBytes = iosFileSize(dest)
+ val expected = expectedSizeBytes.takeIf { it > 0L } ?: stagedBytes
+ if (expectedSizeBytes > 0L && stagedBytes != expectedSizeBytes) {
+ NSFileManager.defaultManager.removeItemAtPath(dest, null)
+ NSFileManager.defaultManager.removeItemAtPath(sourceOkPath(instanceId, clientMessageId), null)
+ throw OutboundFileUnavailableException("Staged file size mismatch")
+ }
+ writeOkMarker(sourceOkPath(instanceId, clientMessageId), stagedBytes, expected)
+ StagedOutboundFile(uri = dest, sizeBytes = stagedBytes)
+}
+
+actual suspend fun isStagedSourceReady(
+ instanceId: String,
+ clientMessageId: String,
+ expectedSizeBytes: Long,
+): Boolean = withContext(Dispatchers.Default) {
+ readOkMarker(
+ sourceOkPath(instanceId, clientMessageId),
+ sourcePath(instanceId, clientMessageId),
+ expectedSizeBytes,
+ )
+}
+
+actual suspend fun isEncryptedBlobReady(
+ instanceId: String,
+ clientMessageId: String,
+ expectedEncryptedSizeBytes: Long?,
+): Boolean = withContext(Dispatchers.Default) {
+ val expected = expectedEncryptedSizeBytes?.takeIf { it > 0L } ?: 0L
+ if (!readOkMarker(blobOkPath(instanceId, clientMessageId), blobPath(instanceId, clientMessageId), expected)) {
+ return@withContext false
+ }
+ NSFileManager.defaultManager.fileExistsAtPath(cipherPath(instanceId, clientMessageId))
+}
+
+actual suspend fun commitEncryptedUploadBlob(
+ instanceId: String,
+ clientMessageId: String,
+ encryptedSizeBytes: Long,
+) {
+ withContext(Dispatchers.Default) {
+ val part = blobPartPath(instanceId, clientMessageId)
+ val final = blobPath(instanceId, clientMessageId)
+ if (NSFileManager.defaultManager.fileExistsAtPath(part)) {
+ atomicReplace(part, final)
+ } else if (!NSFileManager.defaultManager.fileExistsAtPath(final)) {
+ error("Encrypted upload blob missing")
+ }
+ if (iosFileSize(final) != encryptedSizeBytes) {
+ throw OutboundFileUnavailableException("Encrypted blob size mismatch after commit")
+ }
+ writeOkMarker(blobOkPath(instanceId, clientMessageId), encryptedSizeBytes, encryptedSizeBytes)
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+actual suspend fun repairInterruptedUploadArtifacts(instanceId: String, clientMessageId: String) {
+ withContext(Dispatchers.Default) {
+ NSFileManager.defaultManager.removeItemAtPath(sourcePartPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(blobPartPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(cipherPartPath(instanceId, clientMessageId), null)
+ val enc = blobPath(instanceId, clientMessageId)
+ val encOk = blobOkPath(instanceId, clientMessageId)
+ if (!readOkMarker(encOk, enc, 0L)) {
+ NSFileManager.defaultManager.removeItemAtPath(enc, null)
+ NSFileManager.defaultManager.removeItemAtPath(encOk, null)
+ NSFileManager.defaultManager.removeItemAtPath(cipherPath(instanceId, clientMessageId), null)
+ }
+ val source = sourcePath(instanceId, clientMessageId)
+ val sourceOk = sourceOkPath(instanceId, clientMessageId)
+ if (NSFileManager.defaultManager.fileExistsAtPath(source) &&
+ !readOkMarker(sourceOk, source, 0L)
+ ) {
+ NSFileManager.defaultManager.removeItemAtPath(source, null)
+ NSFileManager.defaultManager.removeItemAtPath(sourceOk, null)
+ }
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+private class IosOutboundFileInputStream(
+ private val reader: IosPosixFileReader,
+) : OutboundFileInputStream {
+ override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int =
+ withContext(Dispatchers.Default) {
+ reader.read(buffer, offset, length)
+ }
+
+ override suspend fun close() {
+ withContext(Dispatchers.Default) {
+ reader.close()
+ }
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+actual suspend fun openOutboundFileInputStream(fileUri: String): OutboundFileInputStream? =
+ withContext(Dispatchers.Default) {
+ val path = when {
+ fileUri.startsWith("file://") -> NSURL.URLWithString(fileUri)?.path
+ else -> fileUri
+ } ?: return@withContext null
+ if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return@withContext null
+ runCatching { IosOutboundFileInputStream(IosPosixFileReader(path)) }.getOrNull()
+ }
+
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
private fun readBytesAtPath(path: String): ByteArray? {
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return null
@@ -61,35 +262,6 @@ private fun readBytesAtPath(path: String): ByteArray? {
return ByteArray(length) { i -> bytePtr[i] }
}
-@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
-private fun writeBytesAtPath(path: String, bytes: ByteArray) {
- bytes.usePinned { pinned ->
- val data = NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
- data?.writeToFile(path, true)
- }
-}
-
-@OptIn(ExperimentalForeignApi::class)
-actual suspend fun stageOutboundFileForUpload(
- instanceId: String,
- clientMessageId: String,
- sourceUri: String,
-): StagedOutboundFile = withContext(Dispatchers.Default) {
- val dest = sourcePath(instanceId, clientMessageId)
- if (sourceUri != dest) {
- val existing = readBytesAtPath(dest)
- if (existing == null || existing.isEmpty()) {
- val bytes = readOutboundFileBytes(sourceUri)
- if (bytes.isEmpty()) {
- throw OutboundFileUnavailableException("File is empty or unavailable")
- }
- writeBytesAtPath(dest, bytes)
- }
- }
- val size = readBytesAtPath(dest)?.size?.toLong() ?: 0L
- StagedOutboundFile(uri = dest, sizeBytes = size)
-}
-
@OptIn(ExperimentalForeignApi::class)
actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
withContext(Dispatchers.Default) {
@@ -103,22 +275,85 @@ actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
}
@OptIn(ExperimentalForeignApi::class)
+actual suspend fun copyOutboundFileToPath(sourceUri: String, destinationPath: String) {
+ withContext(Dispatchers.Default) {
+ val sourcePath = when {
+ sourceUri.startsWith("file://") -> NSURL.URLWithString(sourceUri)?.path
+ else -> sourceUri
+ } ?: throw OutboundFileUnavailableException("Invalid file URI")
+ val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = "")
+ if (parent.isNotEmpty()) {
+ NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
+ }
+ NSFileManager.defaultManager.removeItemAtPath(destinationPath, null)
+ iosCopyFile(sourcePath, destinationPath)
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
actual suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray) {
withContext(Dispatchers.Default) {
- writeBytesAtPath(blobPath(instanceId, clientMessageId), bytes)
+ repairInterruptedUploadArtifacts(instanceId, clientMessageId)
+ val part = blobPartPath(instanceId, clientMessageId)
+ NSFileManager.defaultManager.removeItemAtPath(part, null)
+ NSFileManager.defaultManager.removeItemAtPath(blobOkPath(instanceId, clientMessageId), null)
+ bytes.usePinned { pinned ->
+ val data = NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
+ data?.writeToFile(part, true)
+ }
}
}
@OptIn(ExperimentalForeignApi::class)
actual suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? =
withContext(Dispatchers.Default) {
+ if (!isEncryptedBlobReady(instanceId, clientMessageId, null)) return@withContext null
readBytesAtPath(blobPath(instanceId, clientMessageId))
}
-@OptIn(ExperimentalForeignApi::class)
-actual suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String) {
+actual suspend fun encryptedUploadBlobSizeBytes(instanceId: String, clientMessageId: String): Long? =
withContext(Dispatchers.Default) {
- writeBytesAtPath(cipherPath(instanceId, clientMessageId), json.encodeToByteArray())
+ if (!isEncryptedBlobReady(instanceId, clientMessageId, null)) return@withContext null
+ val size = iosFileSize(blobPath(instanceId, clientMessageId))
+ if (size <= 0L) null else size
+ }
+
+actual suspend fun readEncryptedUploadBlobRange(
+ instanceId: String,
+ clientMessageId: String,
+ offset: Long,
+ length: Int,
+): ByteArray = withContext(Dispatchers.Default) {
+ if (!isEncryptedBlobReady(instanceId, clientMessageId, null)) {
+ throw OutboundFileUnavailableException("Encrypted upload blob not committed")
+ }
+ val path = blobPath(instanceId, clientMessageId)
+ if (!NSFileManager.defaultManager.fileExistsAtPath(path)) {
+ throw OutboundFileUnavailableException("Encrypted upload blob missing")
+ }
+ if (length <= 0) return@withContext ByteArray(0)
+ iosReadFileRange(path, offset, length)
+}
+
+@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
+actual suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String) {
+ saveUploadTransportCipherJsonAtomic(instanceId, clientMessageId, json)
+}
+
+@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
+actual suspend fun saveUploadTransportCipherJsonAtomic(
+ instanceId: String,
+ clientMessageId: String,
+ json: String,
+) {
+ withContext(Dispatchers.Default) {
+ val part = cipherPartPath(instanceId, clientMessageId)
+ val final = cipherPath(instanceId, clientMessageId)
+ json.encodeToByteArray().usePinned { pinned ->
+ val data = NSData.create(bytes = pinned.addressOf(0), length = json.length.toULong())
+ data?.writeToFile(part, true)
+ }
+ atomicReplace(part, final)
}
}
@@ -131,9 +366,14 @@ actual suspend fun loadUploadTransportCipherJson(instanceId: String, clientMessa
@OptIn(ExperimentalForeignApi::class)
actual suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String) {
withContext(Dispatchers.Default) {
- NSFileManager.defaultManager.removeItemAtPath(blobPath(instanceId, clientMessageId), null)
- NSFileManager.defaultManager.removeItemAtPath(cipherPath(instanceId, clientMessageId), null)
NSFileManager.defaultManager.removeItemAtPath(sourcePath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(sourcePartPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(sourceOkPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(blobPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(blobPartPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(blobOkPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(cipherPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(cipherPartPath(instanceId, clientMessageId), null)
}
}
@@ -141,6 +381,9 @@ actual suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: Str
actual suspend fun clearUploadSecretsOnly(instanceId: String, clientMessageId: String) {
withContext(Dispatchers.Default) {
NSFileManager.defaultManager.removeItemAtPath(blobPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(blobPartPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(blobOkPath(instanceId, clientMessageId), null)
NSFileManager.defaultManager.removeItemAtPath(cipherPath(instanceId, clientMessageId), null)
+ NSFileManager.defaultManager.removeItemAtPath(cipherPartPath(instanceId, clientMessageId), null)
}
}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/files/FileWriteSink.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/core/files/FileWriteSink.ios.kt
new file mode 100644
index 0000000..8a9aeb3
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/core/files/FileWriteSink.ios.kt
@@ -0,0 +1,45 @@
+package ru.fromchat.core.files
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.convert
+import kotlinx.cinterop.usePinned
+import platform.posix.fclose
+import platform.posix.fflush
+import platform.posix.fopen
+import platform.posix.fwrite
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual class FileWriteSink actual constructor(
+ path: String,
+ append: Boolean,
+) : AutoCloseable {
+ private val file = fopen(
+ path,
+ if (append) "ab" else "wb",
+ ) ?: error("Failed to open file for write: $path")
+
+ actual fun write(buffer: ByteArray, offset: Int, length: Int) {
+ if (length <= 0) return
+ buffer.usePinned { pinned ->
+ val written = fwrite(
+ pinned.addressOf(offset),
+ 1.convert(),
+ length.convert(),
+ file,
+ ).toInt()
+ if (written != length) {
+ error("Short write ($written of $length bytes)")
+ }
+ }
+ }
+
+ actual fun flush() {
+ fflush(file)
+ }
+
+ actual override fun close() {
+ fflush(file)
+ fclose(file)
+ }
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt
index 8daee97..1f92ebd 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/DmCrypto.ios.kt
@@ -85,6 +85,20 @@ actual object DmCrypto {
}
}
+ actual suspend fun decryptAesGcmFileToPath(
+ ivB64: String,
+ encryptedFilePath: String,
+ mek: ByteArray,
+ outputPath: String,
+ ): Long = withContext(Dispatchers.Default) {
+ val iv = Base64
+ .decode(ivB64)
+ .require("IV must be 12 bytes") {
+ it.size == GCM_IV_SIZE
+ }
+ DmFileOps.aesGcmDecryptFileToPath(iv, encryptedFilePath, mek, outputPath)
+ }
+
private suspend fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray {
require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes for GCM" }
require(key.size == AES_KEY_SIZE) { "Key must be 32 bytes" }
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/PlatformAesGcmStreamDecrypt.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/PlatformAesGcmStreamDecrypt.ios.kt
new file mode 100644
index 0000000..fdae88e
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/dm/PlatformAesGcmStreamDecrypt.ios.kt
@@ -0,0 +1,137 @@
+package ru.fromchat.crypto.dm
+
+import com.pr0gramm3r101.utils.files.PlatformFileSystem
+import dev.whyoleg.cryptography.BinarySize.Companion.bits
+import dev.whyoleg.cryptography.CryptographyProvider
+import dev.whyoleg.cryptography.algorithms.AES
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.convert
+import kotlinx.cinterop.usePinned
+import kotlinx.io.Buffer
+import kotlinx.io.RawSink
+import kotlinx.io.RawSource
+import kotlinx.io.buffered
+import platform.posix.fclose
+import platform.posix.fflush
+import platform.posix.fopen
+import platform.posix.fread
+import platform.posix.fwrite
+
+private const val AES_KEY_SIZE = 32
+private const val GCM_IV_SIZE = 12
+private const val GCM_TAG_SIZE = 16
+private const val COPY_BUFFER_BYTES = 256 * 1024
+
+@OptIn(ExperimentalForeignApi::class, dev.whyoleg.cryptography.DelicateCryptographyApi::class)
+internal actual suspend fun platformAesGcmStreamDecryptMekFile(
+ iv: ByteArray,
+ encryptedPath: String,
+ key: ByteArray,
+ outputPath: String,
+): Long {
+ require(key.size == AES_KEY_SIZE) { "MEK must be 32 bytes" }
+ require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" }
+ val encryptedSize = PlatformFileSystem.fileSize(encryptedPath)
+ require(encryptedSize >= GCM_TAG_SIZE) { "Ciphertext too short" }
+
+ val parent = outputPath.substringBeforeLast('/', missingDelimiterValue = "")
+ if (parent.isNotEmpty()) {
+ PlatformFileSystem.ensureDirectory(parent)
+ }
+ if (PlatformFileSystem.exists(outputPath)) {
+ PlatformFileSystem.delete(outputPath)
+ }
+
+ val aesKey = CryptographyProvider.Default
+ .get(AES.GCM)
+ .keyDecoder()
+ .decodeFromByteArray(AES.Key.Format.RAW, key)
+ val cipher = aesKey.cipher(tagSize = 128.bits)
+
+ var plaintextBytes = 0L
+ PosixEncryptedFileRawSource(encryptedPath).buffered().use { encryptedSource ->
+ PlatformFileRawSink(outputPath).buffered().use { plainSink ->
+ cipher.decryptingSinkWithIv(iv, plainSink).buffered().use { decryptSink ->
+ pumpRawSourceToSink(encryptedSource, decryptSink)
+ }
+ }
+ }
+
+ plaintextBytes = PlatformFileSystem.fileSize(outputPath)
+ require(plaintextBytes > 0L) { "Decrypted file is empty" }
+ return plaintextBytes
+}
+
+private suspend fun pumpRawSourceToSink(source: RawSource, sink: kotlinx.io.RawSink) {
+ val chunk = Buffer()
+ while (true) {
+ val read = source.readAtMostTo(chunk, COPY_BUFFER_BYTES.toLong())
+ if (read < 0L) break
+ if (read == 0L) continue
+ sink.write(chunk, read)
+ }
+ sink.flush()
+}
+
+@OptIn(ExperimentalForeignApi::class)
+private class PosixEncryptedFileRawSource(
+ path: String,
+) : RawSource {
+ private val file = fopen(path, "rb") ?: error("Failed to open encrypted file")
+
+ override fun readAtMostTo(sink: Buffer, byteCount: Long): Long {
+ if (byteCount <= 0L) return 0L
+ val toRead = byteCount.coerceAtMost(COPY_BUFFER_BYTES.toLong()).toInt()
+ val array = ByteArray(toRead)
+ val read = array.usePinned { pinned ->
+ fread(pinned.addressOf(0), 1.convert(), toRead.convert(), file).toInt()
+ }
+ if (read <= 0) return -1L
+ sink.write(array, 0, read)
+ return read.toLong()
+ }
+
+ override fun close() {
+ fclose(file)
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+private class PlatformFileRawSink(
+ path: String,
+) : RawSink {
+ private val file = fopen(path, "wb") ?: error("Failed to open output file: $path")
+
+ override fun write(source: Buffer, byteCount: Long) {
+ if (byteCount <= 0L) return
+ var remaining = byteCount
+ while (remaining > 0L) {
+ val toRead = minOf(remaining, COPY_BUFFER_BYTES.toLong()).toInt()
+ val array = ByteArray(toRead)
+ val read = source.readAtMostTo(array, startIndex = 0, endIndex = toRead)
+ if (read <= 0) break
+ array.usePinned { pinned ->
+ val written = fwrite(
+ pinned.addressOf(0),
+ 1.convert(),
+ read.convert(),
+ file,
+ ).toInt()
+ if (written != read) {
+ error("Short write ($written of $read bytes)")
+ }
+ }
+ remaining -= read
+ }
+ }
+
+ override fun flush() {
+ fflush(file)
+ }
+
+ override fun close() {
+ fflush(file)
+ fclose(file)
+ }
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.ios.kt
new file mode 100644
index 0000000..545bd37
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileEncryptor.ios.kt
@@ -0,0 +1,36 @@
+package ru.fromchat.crypto.transport
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import platform.Foundation.NSFileManager
+import ru.fromchat.platform.iosAppendFile
+import ru.fromchat.platform.iosFileSize
+
+@OptIn(ExperimentalForeignApi::class)
+actual object TransportFileEncryptor {
+ actual suspend fun encryptPlaintextFileToTransportBlob(
+ sourceUri: String,
+ destinationPath: String,
+ transportPublicKeyB64: String,
+ ephemeralSecretKey: ByteArray,
+ plaintextSizeBytes: Long,
+ onPlaintextProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)?,
+ ): Long = withContext(Dispatchers.Default) {
+ NSFileManager.defaultManager.removeItemAtPath(destinationPath, null)
+ val parent = destinationPath.substringBeforeLast('/', missingDelimiterValue = destinationPath)
+ if (parent.isNotEmpty()) {
+ NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
+ }
+ NSFileManager.defaultManager.createFileAtPath(destinationPath, null, null)
+ encryptPlaintextFileToFcaeBlob(
+ sourceUri = sourceUri,
+ writeBytes = { bytes -> iosAppendFile(destinationPath, bytes) },
+ finish = { iosFileSize(destinationPath) },
+ transportPublicKeyB64 = transportPublicKeyB64,
+ ephemeralSecretKey = ephemeralSecretKey,
+ plaintextSizeBytes = plaintextSizeBytes,
+ onPlaintextProgress = onPlaintextProgress,
+ )
+ }
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.ios.kt
new file mode 100644
index 0000000..6ab4292
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportFileKey.ios.kt
@@ -0,0 +1,45 @@
+package ru.fromchat.crypto.transport
+
+import com.ionspin.kotlin.crypto.LibsodiumInitializer
+import com.ionspin.kotlin.crypto.box.Box
+import com.pr0gramm3r101.utils.crypto.Base64
+import dev.whyoleg.cryptography.BinarySize.Companion.bits
+import dev.whyoleg.cryptography.CryptographyProvider
+import dev.whyoleg.cryptography.algorithms.AES
+import dev.whyoleg.cryptography.random.CryptographyRandom
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import ru.fromchat.platform.iosHmacSha256
+
+private const val IV_SIZE = 12
+
+@OptIn(dev.whyoleg.cryptography.DelicateCryptographyApi::class)
+private val aesGcm get() = CryptographyProvider.Default.get(AES.GCM)
+
+internal actual fun deriveTransportFileAesKey(
+ transportPublicKeyB64: String,
+ ephemeralSecretKey: ByteArray,
+): ByteArray {
+ runBlocking {
+ if (!LibsodiumInitializer.isInitialized()) {
+ LibsodiumInitializer.initialize()
+ }
+ }
+ val transportPublicKey = Base64.decode(transportPublicKeyB64).toUByteArray()
+ val shared = Box.beforeNM(transportPublicKey, ephemeralSecretKey.toUByteArray()).toByteArray()
+ return hkdfTransportFileKey(shared)
+}
+
+@OptIn(dev.whyoleg.cryptography.DelicateCryptographyApi::class)
+internal actual suspend fun aesGcmEncryptChunk(
+ key: ByteArray,
+ plaintext: ByteArray,
+): Pair = withContext(Dispatchers.Default) {
+ val iv = CryptographyRandom.nextBytes(IV_SIZE)
+ val cipherKey = aesGcm.keyDecoder().decodeFromByteArray(AES.Key.Format.RAW, key)
+ val ciphertext = cipherKey.cipher(tagSize = 128.bits).encryptWithIv(iv, plaintext)
+ iv to ciphertext
+}
+
+internal actual fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray = iosHmacSha256(key, data)
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosHmac.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosHmac.kt
new file mode 100644
index 0000000..6e436ec
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosHmac.kt
@@ -0,0 +1,30 @@
+package ru.fromchat.platform
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.UByteVar
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.convert
+import kotlinx.cinterop.refTo
+import kotlinx.cinterop.usePinned
+import platform.CoreCrypto.CCHmac
+import platform.CoreCrypto.kCCHmacAlgSHA256
+
+@OptIn(ExperimentalForeignApi::class)
+internal fun iosHmacSha256(key: ByteArray, data: ByteArray): ByteArray {
+ val mac = ByteArray(32)
+ key.usePinned { keyPinned ->
+ data.usePinned { dataPinned ->
+ mac.usePinned { macPinned ->
+ CCHmac(
+ algorithm = kCCHmacAlgSHA256,
+ key = keyPinned.addressOf(0),
+ keyLength = key.size.convert(),
+ data = dataPinned.addressOf(0),
+ dataLength = data.size.convert(),
+ macOut = macPinned.addressOf(0),
+ )
+ }
+ }
+ }
+ return mac
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosPosixFiles.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosPosixFiles.kt
new file mode 100644
index 0000000..e293462
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/IosPosixFiles.kt
@@ -0,0 +1,101 @@
+package ru.fromchat.platform
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.convert
+import kotlinx.cinterop.usePinned
+import platform.Foundation.NSFileManager
+import platform.Foundation.NSNumber
+import platform.posix.SEEK_SET
+import platform.posix.fclose
+import platform.posix.fopen
+import platform.posix.fread
+import platform.posix.fseek
+import platform.posix.fwrite
+
+@OptIn(ExperimentalForeignApi::class)
+internal fun iosFileSize(path: String): Long {
+ if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return 0L
+ val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, null) ?: return 0L
+ return (attrs["NSFileSize"] as? NSNumber)?.longValue ?: 0L
+}
+
+@OptIn(ExperimentalForeignApi::class)
+internal fun iosReadFileRange(path: String, offset: Long, length: Int): ByteArray {
+ if (length <= 0) return ByteArray(0)
+ val file = fopen(path, "rb") ?: error("Failed to open file")
+ try {
+ fseek(file, offset, SEEK_SET)
+ val buffer = ByteArray(length)
+ buffer.usePinned { pinned ->
+ val read = fread(pinned.addressOf(0), 1.convert(), length.convert(), file).toInt()
+ if (read < length) {
+ error("File truncated")
+ }
+ }
+ return buffer
+ } finally {
+ fclose(file)
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+internal fun iosAppendFile(path: String, bytes: ByteArray) {
+ if (bytes.isEmpty()) return
+ val file = fopen(path, "ab") ?: error("Failed to open file for append")
+ try {
+ bytes.usePinned { pinned ->
+ val written = fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file).toInt()
+ if (written != bytes.size) {
+ error("Short write")
+ }
+ }
+ } finally {
+ fclose(file)
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+internal fun iosCopyFile(sourcePath: String, destinationPath: String) {
+ val input = fopen(sourcePath, "rb") ?: error("Failed to open source file")
+ val output = fopen(destinationPath, "wb") ?: run {
+ fclose(input)
+ error("Failed to open destination file")
+ }
+ val buffer = ByteArray(256 * 1024)
+ try {
+ while (true) {
+ val read = buffer.usePinned { pinned ->
+ fread(pinned.addressOf(0), 1.convert(), buffer.size.convert(), input).toInt()
+ }
+ if (read <= 0) break
+ buffer.usePinned { pinned ->
+ val written = fwrite(pinned.addressOf(0), 1.convert(), read.convert(), output).toInt()
+ if (written != read) {
+ error("Short write")
+ }
+ }
+ }
+ } finally {
+ fclose(input)
+ fclose(output)
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+internal class IosPosixFileReader(
+ private val path: String,
+) {
+ private val file = fopen(path, "rb") ?: error("Failed to open file")
+
+ fun read(buffer: ByteArray, offset: Int, length: Int): Int {
+ if (length <= 0) return 0
+ return buffer.usePinned { pinned ->
+ fread(pinned.addressOf(offset), 1.convert(), length.convert(), file).toInt()
+ }
+ }
+
+ fun close() {
+ fclose(file)
+ }
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.ios.kt
index bef17f5..3f46800 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentFileDownload.ios.kt
@@ -56,6 +56,10 @@ actual fun rememberCreateDownloadDestinationLauncher(
return remember(onDestination) { launcher }
}
+actual suspend fun persistExportUriPermissionIfNeeded(exportUri: String) {
+ // iOS export URIs are file URLs; no persistable permission grant.
+}
+
private fun defaultDownloadsDirectoryUrl(): NSURL? {
val manager = NSFileManager.defaultManager
return manager.URLForDirectory(
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.ios.kt
new file mode 100644
index 0000000..1182add
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentOpenFeedback.ios.kt
@@ -0,0 +1,5 @@
+package ru.fromchat.ui.chat
+
+internal actual fun showAttachmentOpenFailed(message: String) {
+ // iOS uses share sheet from openCachedAttachmentFile; no-op here.
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.ios.kt
index f430b35..368fc2e 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/DmFileDownloader.ios.kt
@@ -1,46 +1,14 @@
-@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
+actual suspend fun openCachedAttachmentFile(
+ cacheUri: String,
+ mimeType: String,
+ displayFilename: String?,
+): Boolean {
+ val url = NSURL.URLWithString(cacheUri) ?: NSURL.fileURLWithPath(cacheUri.removePrefix("file://"))
return UIApplication.sharedApplication.openURL(url)
}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.ios.kt
new file mode 100644
index 0000000..055678c
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/MessageFileSave.ios.kt
@@ -0,0 +1,86 @@
+@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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import platform.Foundation.NSURL
+import platform.UIKit.UIDocumentPickerDelegateProtocol
+import platform.UIKit.UIDocumentPickerViewController
+import platform.darwin.NSObject
+import ru.fromchat.platform.iosTopViewController
+
+@Composable
+actual fun rememberPlatformSaveMessageFile(
+ onComplete: (Boolean) -> Unit,
+): (SavableMessageFile) -> Unit {
+ val scope = rememberCoroutineScope()
+ var pendingSavable by remember { mutableStateOf(null) }
+ val launcher: (SavableMessageFile) -> Unit = remember {
+ { savable ->
+ scope.launch {
+ val destination = pickSaveDestination(savable.filename, savable.mimeType)
+ val pending = pendingSavable
+ pendingSavable = null
+ if (destination == null || pending == null) {
+ onComplete(false)
+ return@launch
+ }
+ PendingFileSaveRegistry.schedule(
+ PendingFileSaveEntry(
+ storageKey = pending.storageKey,
+ destinationUri = destination,
+ filename = pending.filename,
+ mimeType = pending.mimeType,
+ ),
+ )
+ if (DecryptedFileCache.getCached(
+ messageId = DownloadedFileRegistry.messageIdFromStorageKey(pending.storageKey) ?: -1,
+ fileIndex = DownloadedFileRegistry.fileIndexFromStorageKey(pending.storageKey) ?: 0,
+ ) == null
+ ) {
+ // Download will trigger copy when cache is ready.
+ }
+ onComplete(true)
+ }
+ }
+ }
+ return remember(launcher) {
+ { savable: SavableMessageFile ->
+ pendingSavable = savable
+ launcher(savable)
+ }
+ }
+}
+
+private suspend fun pickSaveDestination(filename: String, mimeType: String): String? =
+ suspendCancellableCoroutine { cont ->
+ val host = iosTopViewController()
+ if (host == null) {
+ cont.resume(null) {}
+ return@suspendCancellableCoroutine
+ }
+ val picker = UIDocumentPickerViewController(forExportingURLs = emptyList(), asCopy = true)
+ val delegate = object : NSObject(), UIDocumentPickerDelegateProtocol {
+ override fun documentPicker(
+ controller: UIDocumentPickerViewController,
+ didPickDocumentsAtURLs: List<*>,
+ ) {
+ val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL
+ cont.resume(url?.absoluteString) {}
+ }
+
+ override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
+ cont.resume(null) {}
+ }
+ }
+ picker.delegate = delegate
+ host.presentViewController(picker, animated = true, completion = null)
+ }
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.ios.kt
new file mode 100644
index 0000000..aae6fcd
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/PendingFileSaveRegistry.ios.kt
@@ -0,0 +1,47 @@
+@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.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import platform.Foundation.NSData
+import platform.Foundation.NSURL
+import platform.Foundation.create
+import platform.Foundation.writeToFile
+import platform.Foundation.writeToURL
+import ru.fromchat.core.cache.readOutboundFileBytes
+
+private val copyScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+internal actual fun enqueuePlatformCopy(storageKey: String) {
+ copyScope.launch {
+ val entry = PendingFileSaveRegistry.listPending()
+ .firstOrNull { it.storageKey == storageKey } ?: return@launch
+ val cacheUri = DecryptedFileCache.getCachedUriForStorageKey(storageKey) ?: return@launch
+ if (cachedAttachmentFileSize(cacheUri) <= 0L) return@launch
+ val bytes = runCatching { readOutboundFileBytes(cacheUri) }.getOrNull() ?: return@launch
+ if (bytes.isEmpty()) return@launch
+ val ok = writeBytesToDestinationUri(entry.destinationUri, bytes)
+ if (ok) {
+ PendingFileSaveRegistry.remove(storageKey)
+ }
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+private fun writeBytesToDestinationUri(destinationUri: String, bytes: ByteArray): Boolean {
+ val url = NSURL.URLWithString(destinationUri)
+ ?: NSURL.fileURLWithPath(destinationUri.removePrefix("file://"))
+ val nsData = bytes.usePinned { pinned ->
+ NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
+ } ?: return false
+ return nsData.writeToURL(url, true) || run {
+ val path = url.path ?: return false
+ nsData.writeToFile(path, true)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index bc07bfa..feed544 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -44,6 +44,7 @@ krypto = "4.0.10"
sqldelight = "2.3.2"
livekitAndroid = "2.25.2"
livekitAndroidComposeComponents = "2.3.0"
+bouncycastle = "1.79"
[libraries]
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
@@ -84,6 +85,7 @@ ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
@@ -113,6 +115,7 @@ sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", ver
sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekitAndroid" }
livekit-android-compose-components = { module = "io.livekit:livekit-android-compose-components", version.ref = "livekitAndroidComposeComponents" }
+bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.android.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.android.kt
index f0688cd..7187eee 100644
--- a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.android.kt
+++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.android.kt
@@ -12,6 +12,17 @@ internal actual fun expectWriteBytes(path: String, bytes: ByteArray) {
file.writeBytes(bytes)
}
+internal actual fun expectAppendBytes(path: String, bytes: ByteArray) {
+ val file = File(path)
+ file.parentFile?.mkdirs()
+ file.appendBytes(bytes)
+}
+
+internal actual fun expectFileSize(path: String): Long {
+ val file = File(path)
+ return if (file.isFile) file.length() else 0L
+}
+
internal actual fun expectDelete(path: String) {
File(path).delete()
}
@@ -26,6 +37,12 @@ internal actual fun expectDeleteFilesWithPrefix(dirPath: String, namePrefix: Str
}
}
+internal actual fun expectListFileNamesInDirectory(dirPath: String): List {
+ val dir = File(dirPath)
+ if (!dir.isDirectory) return emptyList()
+ return dir.listFiles()?.map { it.name } ?: emptyList()
+}
+
internal actual fun expectGetAppCacheDirectory(): String =
UtilsLibrary.context.cacheDir.absolutePath
diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.kt
index 9ba2d79..40af8ee 100644
--- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.kt
+++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.kt
@@ -18,6 +18,15 @@ object PlatformFileSystem {
expectWriteBytes(path, bytes)
}
+ /** Appends [bytes] to the file at [path], creating it when missing. */
+ fun appendBytes(path: String, bytes: ByteArray) {
+ if (bytes.isEmpty()) return
+ expectAppendBytes(path, bytes)
+ }
+
+ /** Size in bytes, or 0 when the file does not exist. */
+ fun fileSize(path: String): Long = expectFileSize(path)
+
/**
* Deletes the file at [path]. No-op if the file does not exist.
*/
@@ -32,6 +41,10 @@ object PlatformFileSystem {
expectDeleteFilesWithPrefix(dirPath, namePrefix)
}
+ /** Returns filenames (not paths) in [dirPath], or empty if the directory is missing. */
+ fun listFileNamesInDirectory(dirPath: String): List =
+ expectListFileNamesInDirectory(dirPath)
+
/**
* Returns the platform-specific application cache directory path.
* On Android: context.cacheDir (requires UtilsLibrary.init to be called).
@@ -51,7 +64,10 @@ object PlatformFileSystem {
internal expect fun expectExists(path: String): Boolean
internal expect fun expectWriteBytes(path: String, bytes: ByteArray)
+internal expect fun expectAppendBytes(path: String, bytes: ByteArray)
+internal expect fun expectFileSize(path: String): Long
internal expect fun expectDelete(path: String)
internal expect fun expectDeleteFilesWithPrefix(dirPath: String, namePrefix: String)
+internal expect fun expectListFileNamesInDirectory(dirPath: String): List
internal expect fun expectGetAppCacheDirectory(): String
internal expect fun expectEnsureDirectory(path: String)
diff --git a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.ios.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.ios.kt
index f3f6304..370131b 100644
--- a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.ios.kt
+++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/files/PlatformFileSystem.ios.kt
@@ -7,11 +7,18 @@ import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
+import platform.Foundation.NSNumber
import platform.Foundation.NSCachesDirectory
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
import platform.Foundation.create
import platform.Foundation.writeToFile
+import platform.posix.fclose
+import platform.posix.fopen
+import platform.posix.fwrite
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.convert
+import kotlinx.cinterop.usePinned
@OptIn(ExperimentalForeignApi::class)
internal actual fun expectExists(path: String): Boolean =
@@ -29,6 +36,32 @@ internal actual fun expectWriteBytes(path: String, bytes: ByteArray) {
nsData?.writeToFile(path, true)
}
+@OptIn(ExperimentalForeignApi::class)
+internal actual fun expectAppendBytes(path: String, bytes: ByteArray) {
+ val parent = path.substringBeforeLast('/', missingDelimiterValue = "")
+ if (parent.isNotEmpty()) {
+ NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null)
+ }
+ val file = fopen(path, "ab") ?: error("Failed to open file for append")
+ try {
+ bytes.usePinned { pinned ->
+ val written = fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file).toInt()
+ if (written != bytes.size) {
+ error("Short write")
+ }
+ }
+ } finally {
+ fclose(file)
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual fun expectFileSize(path: String): Long {
+ if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return 0L
+ val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, null) ?: return 0L
+ return (attrs["NSFileSize"] as? NSNumber)?.longValue ?: 0L
+}
+
internal actual fun expectDelete(path: String) {
NSFileManager.defaultManager.removeItemAtPath(path, null)
}
@@ -43,6 +76,12 @@ internal actual fun expectDeleteFilesWithPrefix(dirPath: String, namePrefix: Str
}
}
+internal actual fun expectListFileNamesInDirectory(dirPath: String): List {
+ val contents = NSFileManager.defaultManager.contentsOfDirectoryAtPath(dirPath, null)
+ ?: return emptyList()
+ return (contents as List<*>).filterIsInstance()
+}
+
internal actual fun expectGetAppCacheDirectory(): String {
val paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, true)
return (paths.firstOrNull() as? String) ?: ""