mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement robust file downloading, uploading and opening
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -2,6 +2,10 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
init() {
|
||||
IosApplicationBootstrapKt.launchOnApplicationStart()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
@@ -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"
|
||||
|
||||
+28
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+21
@@ -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()
|
||||
}
|
||||
+13
@@ -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)
|
||||
}
|
||||
+284
-21
@@ -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" }
|
||||
|
||||
+70
@@ -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
|
||||
}
|
||||
+32
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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)
|
||||
}
|
||||
+193
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -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)
|
||||
}
|
||||
+4
@@ -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
|
||||
|
||||
+8
@@ -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">Can’t 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
|
||||
}
|
||||
}
|
||||
|
||||
+242
-76
@@ -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) {
|
||||
|
||||
+12
-1
@@ -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()
|
||||
+23
@@ -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")
|
||||
}
|
||||
+61
@@ -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. */
|
||||
|
||||
+25
@@ -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()
|
||||
}
|
||||
+104
-8
@@ -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,
|
||||
)
|
||||
}
|
||||
+69
@@ -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
|
||||
}
|
||||
+23
@@ -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]).
|
||||
|
||||
+126
-18
@@ -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,
|
||||
/** 0–100 upload progress when isUploading; null = indefinite */
|
||||
uploadProgress: Int? = null,
|
||||
uploadError: String? = null,
|
||||
onRetryUpload: (() -> Unit)? = null,
|
||||
fileThumbnail: String? = null,
|
||||
fileAspectRatio: Float? = null,
|
||||
fileSizeBytes: Long? = null,
|
||||
@@ -120,7 +129,8 @@ fun AttachmentPreview(
|
||||
isAuthor: Boolean = false,
|
||||
/** Message text shown in attachment download/upload logs. */
|
||||
messageLabel: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
onCancelUpload: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isImage = when {
|
||||
file != null -> isImageFilename(file.name)
|
||||
@@ -157,7 +167,10 @@ fun AttachmentPreview(
|
||||
isAuthor = isAuthor,
|
||||
isUploading = isPendingFile && (isUploading || awaitingServerAck),
|
||||
uploadProgress = if (isPendingFile) uploadProgress else null,
|
||||
uploadError = if (isPendingFile) uploadError else null,
|
||||
onRetryUpload = if (isPendingFile) onRetryUpload else null,
|
||||
messageLabel = messageLabel,
|
||||
onCancelUpload = onCancelUpload,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -235,7 +248,10 @@ fun AttachmentPreview(
|
||||
isUploading = isUploading,
|
||||
awaitingServerAck = awaitingServerAck,
|
||||
uploadProgress = uploadProgress,
|
||||
uploadError = uploadError,
|
||||
onRetryUpload = onRetryUpload,
|
||||
messageLabel = messageLabel,
|
||||
onCancelUpload = onCancelUpload,
|
||||
onFullyLoaded = { if (it) isFullyLoaded = true },
|
||||
)
|
||||
}
|
||||
@@ -262,9 +278,13 @@ private fun ChatImageTileContent(
|
||||
isUploading: Boolean,
|
||||
awaitingServerAck: Boolean,
|
||||
uploadProgress: Int?,
|
||||
uploadError: String? = null,
|
||||
onRetryUpload: (() -> Unit)? = null,
|
||||
messageLabel: String? = null,
|
||||
onCancelUpload: (() -> Unit)? = null,
|
||||
onFullyLoaded: (Boolean) -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipShape = attachmentImageCornerShape(isAuthor)
|
||||
val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f }
|
||||
@@ -315,6 +335,17 @@ private fun ChatImageTileContent(
|
||||
val decryptFailed = remember(downloadProgressByKey, messageId, fileIndex, cacheClientId) {
|
||||
AttachmentDownloadNotifier.isFailed(messageId, fileIndex, cacheClientId)
|
||||
}
|
||||
val downloadCancelled = remember(downloadProgressByKey, messageId, fileIndex, cacheClientId) {
|
||||
AttachmentDownloadNotifier.isCancelled(messageId, fileIndex, cacheClientId) ||
|
||||
AttachmentDownloadNotifier.hasResumablePartial(messageId, fileIndex, cacheClientId)
|
||||
}
|
||||
LaunchedEffect(messageId, fileIndex, cacheClientId) {
|
||||
AttachmentDownloadNotifier.restorePausedForAttachment(
|
||||
messageId = messageId,
|
||||
fileIndex = fileIndex,
|
||||
clientMessageId = cacheClientId,
|
||||
)
|
||||
}
|
||||
var isAwaitingNetworkFull by remember(decryptCacheKey) { mutableStateOf(false) }
|
||||
var loadAttempt by remember(decryptCacheKey) { mutableIntStateOf(0) }
|
||||
LaunchedEffect(showOutboundBlurOverlay) {
|
||||
@@ -565,8 +596,9 @@ private fun ChatImageTileContent(
|
||||
|
||||
val isDownloadingFullImage = !isOutboundPending && fullBitmap == null &&
|
||||
(downloadProgress != null || isAwaitingNetworkFull)
|
||||
val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay
|
||||
val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !isOutboundPending
|
||||
val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay && !downloadCancelled
|
||||
val showDownloadCancelledOverlay = downloadCancelled && fullBitmap == null && !isOutboundPending
|
||||
val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !isOutboundPending && !downloadCancelled
|
||||
val showSpinnerOnly = fullBitmap == null && thumbBitmap == null && !hasLocalSource &&
|
||||
!showLoadFailedOverlay &&
|
||||
!showOutboundBlurOverlay &&
|
||||
@@ -634,19 +666,62 @@ private fun ChatImageTileContent(
|
||||
)
|
||||
LaunchedEffect(full) { onFullyLoaded(true) }
|
||||
}
|
||||
if (showDownloadProgressOverlay) {
|
||||
AnimatedVisibility(
|
||||
visible = showDownloadProgressOverlay,
|
||||
enter = scaleIn(
|
||||
initialScale = 0.82f,
|
||||
animationSpec = tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing),
|
||||
) + fadeIn(tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing)),
|
||||
exit = scaleOut(
|
||||
targetScale = 0.82f,
|
||||
animationSpec = tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing),
|
||||
) + fadeOut(tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ExpressiveUploadIndicator(
|
||||
uploadProgress = downloadProgress,
|
||||
CancellableAttachmentProgressIndicator(
|
||||
progress = downloadProgress,
|
||||
onCancel = {
|
||||
scope.launch {
|
||||
AttachmentDownloadNotifier.cancelDownload(
|
||||
messageId = messageId,
|
||||
fileIndex = fileIndex,
|
||||
clientMessageId = cacheClientId,
|
||||
)
|
||||
AttachmentDownloadScheduler.cancel(decryptCacheKey)
|
||||
}
|
||||
},
|
||||
showCloseScrim = true,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
}
|
||||
} else if (showSpinnerOnly) {
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = showDownloadCancelledOverlay,
|
||||
enter = scaleIn(
|
||||
initialScale = 0.82f,
|
||||
animationSpec = tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing),
|
||||
) + fadeIn(tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing)),
|
||||
exit = scaleOut(
|
||||
targetScale = 0.82f,
|
||||
animationSpec = tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing),
|
||||
) + fadeOut(tween(AttachmentLeadingTransitionMs, easing = FastOutSlowInEasing)),
|
||||
) {
|
||||
DownloadCancelledImageOverlay(
|
||||
isAuthor = isAuthor,
|
||||
onRetryDownload = {
|
||||
AttachmentDownloadNotifier.beginDownload(messageId, fileIndex, cacheClientId)
|
||||
decryptFinished = false
|
||||
loadAttempt++
|
||||
},
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
}
|
||||
if (showSpinnerOnly) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -658,7 +733,7 @@ private fun ChatImageTileContent(
|
||||
AttachmentImageLoadFailedOverlay(
|
||||
isAuthor = isAuthor,
|
||||
onRetry = {
|
||||
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
|
||||
AttachmentDownloadNotifier.beginDownload(messageId, fileIndex, cacheClientId)
|
||||
decryptFinished = false
|
||||
ApiClient.clearPartialEncryptedDownload(decryptCacheKey)
|
||||
loadAttempt++
|
||||
@@ -674,11 +749,60 @@ private fun ChatImageTileContent(
|
||||
uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null,
|
||||
clipShape = clipShape,
|
||||
contentScale = imageContentScale,
|
||||
onCancelUpload = if (isUploading && !awaitingServerAck) onCancelUpload else null,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.alpha(outboundOverlayAlpha.value),
|
||||
)
|
||||
}
|
||||
if (isOutboundPending && !uploadError.isNullOrBlank() && onRetryUpload != null) {
|
||||
AttachmentUploadFailedOverlay(
|
||||
isAuthor = isAuthor,
|
||||
errorKey = uploadError,
|
||||
onRetry = onRetryUpload,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
private fun DownloadCancelledImageOverlay(
|
||||
isAuthor: Boolean,
|
||||
onRetryDownload: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrim = MaterialTheme.colorScheme.scrim.copy(alpha = 0.38f)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.hazeEffect(style = HazeMaterials.thin())
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = scrim,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.scaleOnPress(scale = 0.92f, onClick = onRetryDownload, indication = null),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Download,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = if (isAuthor) {
|
||||
Color.White
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,6 +813,7 @@ private fun UploadingImageOverlay(
|
||||
uploadProgress: Int?,
|
||||
clipShape: RoundedCornerShape,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
onCancelUpload: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
@@ -711,17 +836,26 @@ private fun UploadingImageOverlay(
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
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.
|
||||
}
|
||||
+5
@@ -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
|
||||
+287
-44
@@ -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" }
|
||||
|
||||
+137
@@ -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)
|
||||
}
|
||||
}
|
||||
+36
@@ -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
Reference in New Issue
Block a user