Implement robust file downloading, uploading and opening

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-05-29 20:23:52 +03:00
Unverified
parent 76645e2633
commit 70a9f8635e
104 changed files with 5767 additions and 881 deletions
+12
View File
@@ -4,6 +4,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Required when handing APKs to com.google.android.packageinstaller via ACTION_VIEW. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:enableOnBackInvokedCallback="true"
@@ -52,5 +54,15 @@
<action android:name="${applicationId}.NOTIFICATION_REPLY" />
</intent-filter>
</receiver>
<provider
android:name="ru.fromchat.core.files.AttachmentFileProvider"
android:authorities="${applicationId}.attachment_files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/fromchat_attachment_paths" />
</provider>
</application>
</manifest>
+3 -18
View File
@@ -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()
}
}
}
@@ -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/<idOrUsername>."
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,40 +104,27 @@ 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
}
if (resolvedProfileId == null) {
return baseState.copy(profileLookupErrorMessage = lookupFailureMessage ?: PROFILE_OPEN_FAILED_MESSAGE)
}
if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
markMessagesAsRead()
}
val profileUserId = profileTarget.userId
val profileUsername = profileTarget.username?.trim().orEmpty()
if (profileUserId != null && profileUserId > 0) {
return baseState.copy(
startAtProfileUserId = resolvedProfileId,
startAtProfileUserId = profileUserId,
startAtProfileUsername = null,
startAtDmConversationUserId = null,
startAtPublicChat = false
startAtPublicChat = false,
)
}
if (profileUsername.isNotEmpty()) {
return baseState.copy(
startAtProfileUserId = null,
startAtProfileUsername = profileUsername,
startAtDmConversationUserId = null,
startAtPublicChat = false,
)
}
return baseState
}
private fun applyLaunchState(launchState: ProfileDeepLinkResolution) {
scrollToMessageId = launchState.scrollToMessageId
@@ -235,9 +215,7 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
enableEdgeToEdge()
lifecycleScope.launch {
val launchState = buildLaunchState(intent)
applyLaunchState(launchState)
applyLaunchState(parseLaunchStateFromIntent(intent))
setContent {
App(
scrollToMessageId = scrollToMessageId,
@@ -252,6 +230,7 @@ class MainActivity : ComponentActivity() {
)
}
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() {
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="decrypted_files" path="decrypted_files/" />
<cache-path name="decrypted_images" path="decrypted_images/" />
<!-- Outbound upload staging (instance uploads/) -->
<cache-path name="fromchat" path="fromchat/" />
</paths>
+4
View File
@@ -2,6 +2,10 @@ import SwiftUI
@main
struct iOSApp: App {
init() {
IosApplicationBootstrapKt.launchOnApplicationStart()
}
var body: some Scene {
WindowGroup {
ContentView()
+3
View File
@@ -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)
@@ -12,6 +12,14 @@
android:name="ru.fromchat.calls.CallForegroundService"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
<service
android:name="ru.fromchat.download.AttachmentDownloadForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name="ru.fromchat.download.AttachmentFileCopyForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
@@ -0,0 +1,28 @@
package ru.fromchat.api
import com.pr0gramm3r101.utils.UtilsLibrary
import ru.fromchat.download.AttachmentDownloadForegroundService
actual object AttachmentDownloadForeground {
actual fun onFileDownloadStarted(storageKey: String) {
AttachmentDownloadForegroundService.onJobStarted(
UtilsLibrary.context.applicationContext,
storageKey,
)
}
actual fun onFileDownloadProgress(percent: Int, displayLabel: String?) {
AttachmentDownloadForegroundService.updateProgress(
UtilsLibrary.context.applicationContext,
percent,
displayLabel,
)
}
actual fun onFileDownloadFinished(storageKey: String) {
AttachmentDownloadForegroundService.onJobFinished(
UtilsLibrary.context.applicationContext,
storageKey,
)
}
}
@@ -0,0 +1,21 @@
package ru.fromchat.api
import com.pr0gramm3r101.utils.UtilsLibrary
import ru.fromchat.download.AttachmentFileCopyForegroundService
actual object AttachmentFileCopyForeground {
actual fun onCopyStarted(storageKey: String, displayLabel: String?) {
AttachmentFileCopyForegroundService.onJobStarted(
UtilsLibrary.context.applicationContext,
storageKey,
displayLabel,
)
}
actual fun onCopyFinished(storageKey: String) {
AttachmentFileCopyForegroundService.onJobFinished(
UtilsLibrary.context.applicationContext,
storageKey,
)
}
}
@@ -0,0 +1,15 @@
package ru.fromchat.api
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.HttpTimeoutConfig
internal actual fun encryptedDownloadHttpClient(): HttpClient =
HttpClient(OkHttp) {
install(HttpTimeout) {
connectTimeoutMillis = 15_000
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
}
}
@@ -0,0 +1,9 @@
package ru.fromchat.core
import ru.fromchat.fcm.ensureFcmTokenRegistered
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
actual suspend fun syncPushTokenAfterStartup() {
uploadPendingFcmTokenIfAvailable()
ensureFcmTokenRegistered()
}
@@ -0,0 +1,13 @@
package ru.fromchat.core.cache
private const val MIN_BYTES = 512L * 1024L
private const val MAX_BYTES = 48L * 1024L * 1024L
actual fun maxInMemoryEncryptPlaintextBytes(): Long {
val runtime = Runtime.getRuntime()
val used = runtime.totalMemory() - runtime.freeMemory()
val headroom = (runtime.maxMemory() - used).coerceAtLeast(0L)
// Plaintext + ciphertext + transient buffers during NaCl box.
val budget = headroom / 3L
return budget.coerceIn(MIN_BYTES, MAX_BYTES)
}
@@ -1,8 +1,10 @@
package ru.fromchat.core.cache
import android.net.Uri
import android.content.res.AssetFileDescriptor
import com.pr0gramm3r101.utils.UtilsLibrary
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -11,43 +13,223 @@ private fun uploadDir(instanceId: String): File {
return File(UtilsLibrary.context.cacheDir, "fromchat/instances/$safe/uploads").apply { mkdirs() }
}
private fun blobFile(instanceId: String, clientMessageId: String): File {
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
return File(uploadDir(instanceId), "$safeId.enc")
private fun safeId(clientMessageId: String): String =
clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
private fun sourceFile(instanceId: String, clientMessageId: String): File =
File(uploadDir(instanceId), "${safeId(clientMessageId)}.source")
private fun sourcePartFile(instanceId: String, clientMessageId: String): File =
File(uploadDir(instanceId), "${safeId(clientMessageId)}.source.part")
private fun sourceOkFile(instanceId: String, clientMessageId: String): File =
File(uploadDir(instanceId), "${safeId(clientMessageId)}.source.ok")
private fun blobFile(instanceId: String, clientMessageId: String): File =
File(uploadDir(instanceId), "${safeId(clientMessageId)}.enc")
private fun blobPartFile(instanceId: String, clientMessageId: String): File =
File(uploadDir(instanceId), "${safeId(clientMessageId)}.enc.part")
private fun blobOkFile(instanceId: String, clientMessageId: String): File =
File(uploadDir(instanceId), "${safeId(clientMessageId)}.enc.ok")
private fun cipherFile(instanceId: String, clientMessageId: String): File =
File(uploadDir(instanceId), "${safeId(clientMessageId)}.cipher.json")
private fun cipherPartFile(instanceId: String, clientMessageId: String): File =
File(uploadDir(instanceId), "${safeId(clientMessageId)}.cipher.json.part")
actual fun encryptedUploadBlobPath(instanceId: String, clientMessageId: String): String =
blobFile(instanceId, clientMessageId).absolutePath
actual fun encryptedUploadBlobPartPath(instanceId: String, clientMessageId: String): String =
blobPartFile(instanceId, clientMessageId).absolutePath
private fun readOkMarker(okFile: File, diskFile: File, expectedBytes: Long): Boolean {
if (!okFile.isFile || !diskFile.isFile) return false
val marker = decodeUploadArtifactOkMarker(okFile.readText()) ?: return false
return marker.isValidOnDisk(diskFile.length(), expectedBytes)
}
private fun cipherFile(instanceId: String, clientMessageId: String): File {
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
return File(uploadDir(instanceId), "$safeId.cipher.json")
private fun writeOkMarker(okFile: File, actualBytes: Long, expectedBytes: Long) {
okFile.writeText(encodeUploadArtifactOkMarker(actualBytes, expectedBytes))
}
private fun sourceFile(instanceId: String, clientMessageId: String): File {
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
return File(uploadDir(instanceId), "$safeId.source")
private fun File.syncOutput() {
FileOutputStream(this, true).use { it.fd.sync() }
}
private fun atomicReplace(part: File, final: File) {
if (!part.isFile) error("Partial upload file missing")
final.delete()
if (!part.renameTo(final)) {
part.copyTo(final, overwrite = true)
part.delete()
}
final.syncOutput()
}
actual suspend fun queryOutboundUriSizeBytes(fileUri: String): Long? = withContext(Dispatchers.IO) {
when {
fileUri.startsWith("content://") -> {
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) {
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).inputStream()
else -> File(sourceUri).takeIf { it.isFile }?.inputStream()
} ?: throw OutboundFileUnavailableException("Failed to read file from URI")
input.use { inputStream ->
dest.outputStream().use { output -> inputStream.copyTo(output) }
FileOutputStream(part).use { output ->
inputStream.copyTo(output)
output.flush()
output.fd.sync()
}
}
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")
}
StagedOutboundFile(uri = destUri, sizeBytes = dest.length().coerceAtLeast(0L))
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()
}
}
@@ -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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
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<Any>(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<out String>?): Cursor {
val columns = projection?.takeIf { it.isNotEmpty() }
?: arrayOf(OpenableColumns.DISPLAY_NAME)
return MatrixCursor(columns, 0)
}
}
@@ -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()
}
}
@@ -84,4 +84,5 @@ object BackupCryptoPlatform {
cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec)
cipher.doFinal(ciphertext)
}
}
@@ -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" }
@@ -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
}
@@ -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,
)
}
}
}
@@ -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<ByteArray, ByteArray> = 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)
}
@@ -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<String>()
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)
}
}
}
@@ -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<String>()
@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)
}
}
}
@@ -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 }
}
@@ -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<AttachmentFileCopyWorker>()
.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)
}
@@ -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
@@ -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()
}
@@ -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 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 writeBytesToExportUri(exportUri: String, bytes: ByteArray): Boolean =
withContext(Dispatchers.IO) {
runCatching {
val uri = Uri.parse(exportUri)
UtilsLibrary.context.contentResolver.openOutputStream(uri, "wt")?.use { out ->
out.write(bytes)
} != null
}.getOrDefault(false)
}
actual suspend fun isExportUriAccessible(exportUri: String): Boolean = withContext(Dispatchers.IO) {
runCatching {
when {
exportUri.startsWith("content://") -> {
UtilsLibrary.context.contentResolver
.openFileDescriptor(Uri.parse(exportUri), "r")
?.use { true } == true
}
exportUri.startsWith("file://") -> {
val path = Uri.parse(exportUri).path ?: return@runCatching false
File(path).isFile
}
else -> File(exportUri).isFile
}
}.getOrDefault(false)
}
actual fun openExportUri(exportUri: String, mimeType: String): Boolean {
val context = UtilsLibrary.context
val uri = Uri.parse(
when {
exportUri.startsWith("content://") || exportUri.startsWith("file://") -> exportUri
else -> "file://$exportUri"
},
)
if (uri.scheme == "file") {
val path = uri.path ?: return false
if (!File(path).isFile) return false
}
return runCatching {
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
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)
}
context.startActivity(Intent.createChooser(intent, null))
}
fun buildViewIntent(type: String): Intent =
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(contentUri, type)
}.let(::commonFlags)
fun queryHandlerPackages(pm: PackageManager, intent: Intent): List<String> {
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)
}
private tailrec fun findActivity(ctx: Context?): Activity? = when (ctx) {
is Activity -> ctx
is ContextWrapper -> findActivity(ctx.baseContext)
else -> null
}
@@ -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<SavableMessageFile, Uri?>() {
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<SavableMessageFile?>(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)
}
}
}
@@ -74,7 +74,12 @@
<string name="message_replying_to">Ответ %1$s</string>
<string name="message_corrupted_short">Сообщение не показывается</string>
<string name="attachment_image_load_failed">Не удалось загрузить</string>
<string name="attachment_upload_failed">Не удалось отправить файл</string>
<string name="attachment_upload_failed_too_large">Файл слишком большой для отправки на этом устройстве</string>
<string name="cd_attachment_upload_retry">Повторить отправку файла</string>
<string name="attachment_retry">Повторить</string>
<string name="attachment_open_failed">Не удалось открыть файл. Попробуйте «Сохранить» в меню сообщения.</string>
<string name="attachment_open_chooser_title">Открыть с помощью</string>
<string name="cd_attachment_retry">Повторить загрузку изображения</string>
<string name="message_editing_title">Правка сообщения</string>
<string name="action_reply">Ответить</string>
@@ -84,6 +89,14 @@
<string name="action_cancel_send">Отменить</string>
<string name="notif_media_upload_percent">%1$d\u0025</string>
<string name="notif_media_upload_progress">%1$s · %2$s</string>
<string name="notif_file_copy_channel_name">Сохранение файла</string>
<string name="notif_file_copy_title">Сохранение вложения</string>
<string name="notif_file_copy_text">Копирование файла в фоне</string>
<string name="notif_file_download_channel_name">Загрузка файла</string>
<string name="notif_file_download_title">Загрузка вложения</string>
<string name="notif_file_download_text">Загрузка продолжается в фоне</string>
<string name="notif_file_download_percent">%1$d\u0025</string>
<string name="notif_file_download_progress">%1$s · %2$s</string>
<string name="action_save">Сохранить</string>
<string name="cd_close">Закрыть</string>
<string name="cd_remove">Убрать</string>
@@ -87,7 +87,12 @@
<string name="message_replying_to">Reply to %1$s</string>
<string name="message_corrupted_short">Cant show this message</string>
<string name="attachment_image_load_failed">Failed to load</string>
<string name="attachment_upload_failed">Couldn\'t send file</string>
<string name="attachment_upload_failed_too_large">File is too large to send on this device</string>
<string name="cd_attachment_upload_retry">Retry sending file</string>
<string name="attachment_retry">Retry</string>
<string name="attachment_open_failed">Couldn\'t open this file. Try Save from the message menu.</string>
<string name="attachment_open_chooser_title">Open with</string>
<string name="cd_attachment_retry">Retry loading image</string>
<string name="message_editing_title">Edit message</string>
@@ -100,6 +105,14 @@
<string name="action_save">Save</string>
<string name="notif_media_upload_percent">%1$d\u0025</string>
<string name="notif_media_upload_progress">%1$s · %2$s</string>
<string name="notif_file_copy_channel_name">Saving file</string>
<string name="notif_file_copy_title">Saving attachment</string>
<string name="notif_file_copy_text">Copying file in the background</string>
<string name="notif_file_download_channel_name">File download</string>
<string name="notif_file_download_title">Downloading attachment</string>
<string name="notif_file_download_text">Download continues in the background</string>
<string name="notif_file_download_percent">%1$d\u0025</string>
<string name="notif_file_download_progress">%1$s · %2$s</string>
<!-- Chat input -->
<string name="cd_close">Close</string>
@@ -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-")
}
}
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) }
}
readDownloadBody(
response = response,
prefix = if (offset > 0) ByteArray(0) else prefix,
partialPath = partialPath,
onProgress = onProgress,
)
}
else -> {
val bytes = response.body<ByteArray>()
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 outputPath = resumeKey?.let { partialEncryptedDownloadPath(it) }
?: oneOffEncryptedDownloadPath()
?: error("Encrypted downloads directory unavailable")
val offset = if (PlatformFileSystem.exists(outputPath)) {
PlatformFileSystem.fileSize(outputPath)
} else {
(received / 32_768).coerceIn(1, 99)
0L
}
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
}
val percent = if (totalBytes != null && totalBytes > 0L) {
percentForBytes(receivedBytes, totalBytes)
} else {
(receivedBytes / 32_768L).toInt().coerceIn(1, 99)
}
resumeKey?.let {
savePartialDownloadProgress(
it,
percent,
totalBytes?.coerceAtMost(Int.MAX_VALUE.toLong())?.toInt(),
)
}
reportProgress(percent)
},
)
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)
}
onProgress?.invoke(100)
partialPath?.let { PlatformFileSystem.delete(it) }
return buffer
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 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 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<String>()
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<String> =
pausedDownloadIndexCache.filter { hasResumablePartialOnDisk(it) }
fun listAutoResumablePartialDownloadKeys(): List<String> =
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<String, PartialDownloadMeta>()
private var pausedDownloadIndexCache: Set<String> = 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<String> {
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<String>) {
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
@@ -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)
}
@@ -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<AttachmentDownloadProgress>(extraBufferCapacity = 64)
val progressFlow: SharedFlow<AttachmentDownloadProgress> = _progressFlow
@@ -37,7 +46,12 @@ object AttachmentDownloadNotifier {
private val _failedKeys = MutableStateFlow<Set<String>>(emptySet())
val failedKeys: StateFlow<Set<String>> = _failedKeys.asStateFlow()
private val _cancelledKeys = MutableStateFlow<Set<String>>(emptySet())
val cancelledKeys: StateFlow<Set<String>> = _cancelledKeys.asStateFlow()
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val resumeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val progressThrottleByKey = mutableMapOf<String, DownloadProgressThrottle>()
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) }
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(","),
val mirrorKeys = mirrorKeysFor(
primaryKey = primaryKey,
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = mirrorAsFileAttachment,
)
}
}
when (progress) {
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)
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) {
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<String> = if (mirrorAsFileAttachment) {
DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
} else {
DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
}.toSet()
_progressPercentByKey.update { map -> map - keys }
_failedKeys.update { failed -> failed - keys }
}
fun isFailed(
@@ -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<String> = when {
mirrorAsFileAttachment || primaryKey.startsWith("file_") ->
DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
else ->
DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
}.ifEmpty { listOf(primaryKey) }
private fun applyProgressPercent(keys: List<String>, 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<String>): DownloadProgressThrottle {
val id = keys.firstOrNull() ?: return DownloadProgressThrottle()
return progressThrottleByKey.getOrPut(id) { DownloadProgressThrottle() }
}
}
@@ -0,0 +1,6 @@
package ru.fromchat.api
expect object AttachmentFileCopyForeground {
fun onCopyStarted(storageKey: String, displayLabel: String? = null)
fun onCopyFinished(storageKey: String)
}
@@ -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<DmAttachmentOutboxPayload>(row.payloadJson).clientMessageId.trim()
}.getOrNull().orEmpty()
if (clientMessageId.isEmpty()) continue
repairInterruptedUploadArtifacts(instanceId, clientMessageId)
}
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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. */
@@ -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<Long>? = 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)
@@ -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
}
}
@@ -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<String>,
): 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<String>,
) {
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<ByteArray, TransportCiphertext>? {
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<StoredTransportCipher>(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) {
@@ -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()
@@ -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()
@@ -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")
}
@@ -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. */
@@ -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<UploadArtifactOkMarker>(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
}
@@ -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()
}
@@ -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
@@ -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(
val encryptedOnDisk = ru.fromchat.api.ApiClient.fetchEncryptedFileResumable(
path = file.path,
resumeKey = downloadResumeKey,
onProgress = onDownloadProgress,
)
} else {
ru.fromchat.api.ApiClient.fetchEncryptedFile(file.path)
}
if (encryptedBytes.isEmpty()) {
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)
}
}
}
@@ -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"
@@ -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
}
@@ -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,
)
}
@@ -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)
}
}
@@ -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<ByteArray, ByteArray>
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
}
@@ -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()
}
@@ -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<String?>(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() }
// 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 { ApiClient.loadPersistedData() }
}
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]).
@@ -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<String, Mutex>()
private data class Pending(
val storageKey: String,
@@ -24,24 +36,65 @@ object AttachmentDownloadScheduler {
val enqueuedAt: Long,
val work: suspend () -> String?,
val result: CompletableDeferred<String?>,
val keepAliveInBackground: Boolean,
)
fun isActive(storageKey: String): Boolean =
activeJobs[storageKey]?.isActive == true
private val waiting = mutableListOf<Pending>()
private val keyToDeferred = mutableMapOf<String, CompletableDeferred<String?>>()
private val activeJobs = mutableMapOf<String, Job>()
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<String?>()
keyToDeferred[storageKey] = created
waiting.removeAll { it.storageKey == storageKey }
waiting.add(
Pending(
storageKey = storageKey,
@@ -49,21 +102,19 @@ object AttachmentDownloadScheduler {
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,31 +123,76 @@ object AttachmentDownloadScheduler {
val toStart = mutex.withLock {
val jobs = mutableListOf<Pending>()
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 {
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() }
if (pending.keepAliveInBackground) {
AttachmentDownloadForeground.onFileDownloadStarted(pending.storageKey)
}
try {
val outcome = pending.work()
mutex.withLock {
activeCount = (activeCount - 1).coerceAtLeast(0)
if (!pending.result.isCompleted) {
pending.result.complete(outcome)
}
if (keyToDeferred[pending.storageKey] === pending.result) {
keyToDeferred.remove(pending.storageKey)
}
outcome.fold(
onSuccess = { pending.result.complete(it) },
onFailure = { pending.result.completeExceptionally(it) },
)
}
} 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()
}
}
private fun sortWaitingLocked() {
waiting.sortWith(
@@ -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)
}
@@ -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)
@@ -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"
}
}
@@ -0,0 +1,3 @@
package ru.fromchat.ui.chat
internal expect fun showAttachmentOpenFailed(message: String)
@@ -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,
/** 0100 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
) {
if (onCancelUpload != null) {
CancellableAttachmentProgressIndicator(
progress = uploadProgress,
onCancel = onCancelUpload,
showCloseScrim = false,
modifier = Modifier.size(56.dp),
)
} else {
ExpressiveUploadIndicator(
uploadProgress = uploadProgress,
modifier = Modifier.size(56.dp)
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(
modifier = Modifier.size(leadingSize),
contentAlignment = Alignment.Center
) {
if (isUploading) {
ExpressiveUploadIndicator(
FileAttachmentLeadingSlot(
isProgressing = isUploading,
isDownloaded = isDownloaded,
uploadProgress = uploadProgress,
isAuthor = isAuthor,
onCancelProgress = onCancelProgress ?: {},
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)
) {
@@ -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,
)
}
}
}
@@ -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<String?>(null)
}
var uriAccessible by remember { mutableStateOf(false) }
val downloadProgressByKey by AttachmentDownloadNotifier.progressPercentByKey.collectAsState()
val downloadCancelledKeys by AttachmentDownloadNotifier.cancelledKeys.collectAsState()
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(
AttachmentDownloadNotifier.restorePausedForAttachment(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
}
}
val downloadProgressByKey by AttachmentDownloadNotifier.progressPercentByKey.collectAsState()
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,87 +89,51 @@ fun ChatFileAttachmentTile(
progressByKey = downloadProgressByKey,
)
}
val isDownloading = !isUploading &&
downloadProgress != null &&
downloadProgress < 100
var pendingDownload by remember { mutableStateOf<PendingFileDownload?>(null) }
val launchDestinationPicker = rememberCreateDownloadDestinationLauncher { destination ->
val pending = pendingDownload
pendingDownload = null
if (destination == null || pending == null) return@rememberCreateDownloadDestinationLauncher
scope.launch {
val ok = DmFileDownloader.downloadToExportUri(
messageId = pending.messageId,
fileIndex = pending.fileIndex,
file = pending.file,
envelope = pending.envelope,
currentUserId = pending.currentUserId,
clientMessageId = pending.clientMessageId,
exportUri = destination,
messageLabel = pending.messageLabel,
)
if (ok) {
uriAccessible = isExportUriAccessible(destination)
if (!uriAccessible) {
DownloadedFileRegistry.removeExportUri(
pending.messageId,
pending.fileIndex,
pending.clientMessageId,
)
exportUri = null
}
} else {
exportUri = null
uriAccessible = false
}
}
}
val isDownloaded = !isPendingLocal && uriAccessible && exportUri != null && !isDownloading
val showWavy = isUploading || isDownloading
val onRowClick: (() -> Unit)? = when {
isUploading -> null
isPendingLocal && pendingFileUri != null -> {
{
scope.launch {
withContext(Dispatchers.Default) {
openExportUri(pendingFileUri, mimeType)
}
}
}
}
isDownloaded && exportUri != null -> {
{
scope.launch {
val accessible = isExportUriAccessible(exportUri!!)
if (!accessible) {
DownloadedFileRegistry.removeExportUri(messageId, fileIndex, clientMessageId)
exportUri = null
uriAccessible = false
AttachmentDownloadNotifier.clearProgress(
val downloadPaused = remember(downloadCancelledKeys, messageId, fileIndex, clientMessageId) {
AttachmentDownloadNotifier.isCancelled(
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 -> {
val isDownloading = !isUploading && !uploadFailed &&
downloadProgress != null &&
downloadProgress < 100 &&
!downloadPaused
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 {
openableLocalUri != null -> {
{
pendingDownload = PendingFileDownload(
scope.launch {
val opened = openCachedAttachmentFile(openableLocalUri, mimeType, filename)
if (!opened) {
showAttachmentOpenFailed(openFailedMessage)
}
}
}
}
downloadPaused && file != null && dmEnvelope != null -> {
{
AttachmentDownloadNotifier.beginDownload(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
scope.launch {
val ok = DmFileDownloader.downloadToCache(
messageId = messageId,
fileIndex = fileIndex,
file = file,
@@ -178,34 +142,126 @@ fun ChatFileAttachmentTile(
clientMessageId = clientMessageId,
messageLabel = messageLabel,
)
launchDestinationPicker(filename, mimeType)
if (ok) {
cacheUri = DecryptedFileCache.getCached(messageId, fileIndex, clientMessageId)
AttachmentDownloadNotifier.clearProgress(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
}
}
}
}
file != null && dmEnvelope != null && !isDownloading && !downloadPaused -> {
{
AttachmentDownloadNotifier.beginDownload(
messageId = messageId,
fileIndex = fileIndex,
clientMessageId = clientMessageId,
mirrorAsFileAttachment = true,
)
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
}
val enableRowClick = onRowClick != null &&
(openableLocalUri != null || !showWavy || downloadPaused)
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 = showWavy,
isUploading = showProgressing,
uploadProgress = when {
isUploading -> uploadProgress
isDownloading -> downloadProgress
isUploading && showProgressing -> displayUploadProgress
isDownloading || showPausedProgress -> downloadProgress ?: 0
else -> null
},
isDownloaded = isDownloaded,
modifier = modifier,
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)
}
}
}
}
}
}
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?,
)
@@ -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())
@@ -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 ->
@@ -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<String, String>()
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._-]"), "_")
}
@@ -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,9 +167,17 @@ 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,
@@ -188,8 +196,10 @@ object DecryptedImageCache {
currentUserId = currentUserId,
messageLabel = label,
)
}
},
)
}.onFailure { error ->
if (error !is CancellationException) {
ApiClient.clearPartialEncryptedDownload(key)
AttachmentMediaLog.download(
"decrypt_exception",
@@ -208,6 +218,7 @@ object DecryptedImageCache {
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) }
}
@@ -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,
): Boolean = withContext(Dispatchers.Default) {
DecryptedFileCache.getOrDecrypt(
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)
}
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
@@ -21,6 +21,21 @@ object DownloadedFileRegistry {
private val memory = mutableMapOf<String, String>()
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,
@@ -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,
)
}
}
}
@@ -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,
@@ -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
@@ -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,
@@ -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,
@@ -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,
)
}
@@ -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)
}
@@ -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). */
@@ -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<PendingFileSaveEntry>()
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<PendingFileSaveEntry> {
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<PendingFileSaveEntry> {
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<List<PendingFileSaveEntry>>(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)
@@ -60,6 +60,6 @@ fun PublicChatScreen(
scrollToMessageId = scrollToMessageId,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedContentScope,
sharedAvatarKey = "public-general-chat"
sharedAvatarKey = null,
)
}
@@ -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(
@@ -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 = ?;
@@ -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() }
}
}
}
@@ -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
}
@@ -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
}
@@ -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
}
}
@@ -0,0 +1,5 @@
package ru.fromchat.core
actual suspend fun syncPushTokenAfterStartup() {
// iOS push registration is handled separately when APNs is wired.
}
@@ -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
@@ -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<ByteVar>() ?: 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)
}
}
@@ -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)
}
}
@@ -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" }
@@ -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)
}
}
@@ -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,
)
}
}
@@ -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<ByteArray, ByteArray> = 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)
@@ -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
}
@@ -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)
}
}
@@ -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(
@@ -0,0 +1,5 @@
package ru.fromchat.ui.chat
internal actual fun showAttachmentOpenFailed(message: String) {
// iOS uses share sheet from openCachedAttachmentFile; no-op here.
}
@@ -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)
}
@@ -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<SavableMessageFile?>(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<NSURL>(), 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)
}
@@ -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)
}
}

Some files were not shown because too many files have changed in this diff Show More