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.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<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
|
<application
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
@@ -52,5 +54,15 @@
|
|||||||
<action android:name="${applicationId}.NOTIFICATION_REPLY" />
|
<action android:name="${applicationId}.NOTIFICATION_REPLY" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package ru.fromchat
|
package ru.fromchat
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.util.Log
|
|
||||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -11,9 +10,8 @@ import kotlinx.serialization.json.jsonArray
|
|||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.AttachmentTransferBootstrap
|
||||||
import ru.fromchat.api.WebSocketManager
|
import ru.fromchat.api.WebSocketManager
|
||||||
import ru.fromchat.fcm.ensureFcmTokenRegistered
|
|
||||||
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
|
|
||||||
import ru.fromchat.notifications.NotificationHelper
|
import ru.fromchat.notifications.NotificationHelper
|
||||||
|
|
||||||
class App: Application() {
|
class App: Application() {
|
||||||
@@ -92,21 +90,8 @@ class App: Application() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching { ApiClient.loadPersistedData() }
|
||||||
ApiClient.loadPersistedData()
|
AttachmentTransferBootstrap.launchOnApplicationStart()
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,6 @@ import io.ktor.client.request.post
|
|||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import io.ktor.client.plugins.ClientRequestException
|
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
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_PUBLIC = "public"
|
||||||
private const val CHAT_TYPE_DM = "dm"
|
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 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(
|
private data class ProfileDeepLinkResolution(
|
||||||
val scrollToMessageId: Int? = null,
|
val scrollToMessageId: Int? = null,
|
||||||
val startAtPublicChat: Boolean = false,
|
val startAtPublicChat: Boolean = false,
|
||||||
@@ -53,13 +49,6 @@ private data class ProfileDeepLinkResolution(
|
|||||||
val profileLookupErrorMessage: String? = null
|
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(
|
private data class ProfileDeepLinkTarget(
|
||||||
val userId: Int? = null,
|
val userId: Int? = null,
|
||||||
val username: String? = null,
|
val username: String? = null,
|
||||||
@@ -78,7 +67,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private suspend fun buildLaunchState(intent: Intent?): ProfileDeepLinkResolution {
|
private fun parseLaunchStateFromIntent(intent: Intent?): ProfileDeepLinkResolution {
|
||||||
Logger.d(
|
Logger.d(
|
||||||
"ProfileDeepLink",
|
"ProfileDeepLink",
|
||||||
"handleIntent: action=${intent?.action}, data=${intent?.dataString}, messageId=${intent?.getIntExtra(EXTRA_MESSAGE_ID, -1)}, " +
|
"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}"
|
"handleIntent parsedProfileTarget: userId=${profileTarget?.userId}, username=${profileTarget?.username}, parseError=${profileTarget?.parseError}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
|
||||||
|
markMessagesAsRead()
|
||||||
|
}
|
||||||
|
|
||||||
val baseState = ProfileDeepLinkResolution(
|
val baseState = ProfileDeepLinkResolution(
|
||||||
scrollToMessageId = if (messageId != -1) messageId else null,
|
scrollToMessageId = if (messageId != -1) messageId else null,
|
||||||
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM,
|
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM,
|
||||||
@@ -111,39 +104,26 @@ class MainActivity : ComponentActivity() {
|
|||||||
return baseState
|
return baseState
|
||||||
}
|
}
|
||||||
|
|
||||||
var lookupFailureMessage: String? = null
|
val profileUserId = profileTarget.userId
|
||||||
val resolvedProfileId = if (profileTarget.userId != null) {
|
val profileUsername = profileTarget.username?.trim().orEmpty()
|
||||||
runCatching {
|
if (profileUserId != null && profileUserId > 0) {
|
||||||
ApiClient.getProfileById(profileTarget.userId).id
|
return baseState.copy(
|
||||||
}.onFailure { err ->
|
startAtProfileUserId = profileUserId,
|
||||||
Logger.d("ProfileDeepLink", "profile deep link lookup failed by id: ${err.message}")
|
startAtProfileUsername = null,
|
||||||
lookupFailureMessage = getProfileLookupFailureMessage(err)
|
startAtDmConversationUserId = null,
|
||||||
}.getOrNull()
|
startAtPublicChat = false,
|
||||||
} else if (!profileTarget.username.isNullOrBlank()) {
|
)
|
||||||
runCatching {
|
}
|
||||||
ApiClient.getProfileByUsername(profileTarget.username).id
|
if (profileUsername.isNotEmpty()) {
|
||||||
}.onFailure { err ->
|
return baseState.copy(
|
||||||
Logger.d("ProfileDeepLink", "profile deep link lookup failed by username: ${err.message}")
|
startAtProfileUserId = null,
|
||||||
lookupFailureMessage = getProfileLookupFailureMessage(err)
|
startAtProfileUsername = profileUsername,
|
||||||
}.getOrNull()
|
startAtDmConversationUserId = null,
|
||||||
} else {
|
startAtPublicChat = false,
|
||||||
null
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedProfileId == null) {
|
return baseState
|
||||||
return baseState.copy(profileLookupErrorMessage = lookupFailureMessage ?: PROFILE_OPEN_FAILED_MESSAGE)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
|
|
||||||
markMessagesAsRead()
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseState.copy(
|
|
||||||
startAtProfileUserId = resolvedProfileId,
|
|
||||||
startAtProfileUsername = null,
|
|
||||||
startAtDmConversationUserId = null,
|
|
||||||
startAtPublicChat = false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyLaunchState(launchState: ProfileDeepLinkResolution) {
|
private fun applyLaunchState(launchState: ProfileDeepLinkResolution) {
|
||||||
@@ -235,23 +215,22 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
lifecycleScope.launch {
|
applyLaunchState(parseLaunchStateFromIntent(intent))
|
||||||
val launchState = buildLaunchState(intent)
|
setContent {
|
||||||
applyLaunchState(launchState)
|
App(
|
||||||
setContent {
|
scrollToMessageId = scrollToMessageId,
|
||||||
App(
|
startAtPublicChat = startAtPublicChat,
|
||||||
scrollToMessageId = scrollToMessageId,
|
startAtDmConversationUserId = startAtDmConversationUserId,
|
||||||
startAtPublicChat = startAtPublicChat,
|
startAtProfileUserId = startAtProfileUserId,
|
||||||
startAtDmConversationUserId = startAtDmConversationUserId,
|
startAtProfileUsername = startAtProfileUsername,
|
||||||
startAtProfileUserId = startAtProfileUserId,
|
profileLookupErrorMessage = profileLookupErrorMessage,
|
||||||
startAtProfileUsername = startAtProfileUsername,
|
onProfileLookupErrorMessageConsumed = {
|
||||||
profileLookupErrorMessage = profileLookupErrorMessage,
|
profileLookupErrorMessage = null
|
||||||
onProfileLookupErrorMessageConsumed = {
|
}
|
||||||
profileLookupErrorMessage = null
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
checkGooglePlayServices()
|
checkGooglePlayServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,10 +242,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
lifecycleScope.launch {
|
setIntent(intent)
|
||||||
val launchState = buildLaunchState(intent)
|
applyLaunchState(parseLaunchStateFromIntent(intent))
|
||||||
applyLaunchState(launchState)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
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
|
@main
|
||||||
struct iOSApp: App {
|
struct iOSApp: App {
|
||||||
|
init() {
|
||||||
|
IosApplicationBootstrapKt.launchOnApplicationStart()
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ kotlin {
|
|||||||
|
|
||||||
// Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3
|
// Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||||
implementation(libs.ktor.client.websockets)
|
implementation(libs.ktor.client.websockets)
|
||||||
@@ -83,11 +84,13 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
implementation(libs.bouncycastle.bcprov)
|
||||||
implementation(libs.androidx.exifinterface)
|
implementation(libs.androidx.exifinterface)
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
implementation(libs.firebase.messaging)
|
implementation(libs.firebase.messaging)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
implementation(libs.multiplatform.crypto.libsodium.bindings)
|
||||||
implementation(libs.tweetnacl.java)
|
implementation(libs.tweetnacl.java)
|
||||||
implementation(libs.sqldelight.driver.android)
|
implementation(libs.sqldelight.driver.android)
|
||||||
implementation(libs.livekit.android)
|
implementation(libs.livekit.android)
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
android:name="ru.fromchat.calls.CallForegroundService"
|
android:name="ru.fromchat.calls.CallForegroundService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="camera|microphone" />
|
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
|
<service
|
||||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
android:foregroundServiceType="dataSync"
|
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)
|
||||||
|
}
|
||||||
+290
-27
@@ -1,8 +1,10 @@
|
|||||||
package ru.fromchat.core.cache
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.content.res.AssetFileDescriptor
|
||||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
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() }
|
return File(UtilsLibrary.context.cacheDir, "fromchat/instances/$safe/uploads").apply { mkdirs() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun blobFile(instanceId: String, clientMessageId: String): File {
|
private fun safeId(clientMessageId: String): String =
|
||||||
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
return File(uploadDir(instanceId), "$safeId.enc")
|
|
||||||
|
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 {
|
private fun writeOkMarker(okFile: File, actualBytes: Long, expectedBytes: Long) {
|
||||||
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
okFile.writeText(encodeUploadArtifactOkMarker(actualBytes, expectedBytes))
|
||||||
return File(uploadDir(instanceId), "$safeId.cipher.json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sourceFile(instanceId: String, clientMessageId: String): File {
|
private fun File.syncOutput() {
|
||||||
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
FileOutputStream(this, true).use { it.fd.sync() }
|
||||||
return File(uploadDir(instanceId), "$safeId.source")
|
}
|
||||||
|
|
||||||
|
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(
|
actual suspend fun stageOutboundFileForUpload(
|
||||||
instanceId: String,
|
instanceId: String,
|
||||||
clientMessageId: String,
|
clientMessageId: String,
|
||||||
sourceUri: String,
|
sourceUri: String,
|
||||||
|
expectedSizeBytes: Long,
|
||||||
): StagedOutboundFile = withContext(Dispatchers.IO) {
|
): StagedOutboundFile = withContext(Dispatchers.IO) {
|
||||||
|
repairInterruptedUploadArtifacts(instanceId, clientMessageId)
|
||||||
val dest = sourceFile(instanceId, clientMessageId)
|
val dest = sourceFile(instanceId, clientMessageId)
|
||||||
val destUri = Uri.fromFile(dest).toString()
|
val destUri = Uri.fromFile(dest).toString()
|
||||||
if (sourceUri != destUri && sourceUri != dest.absolutePath) {
|
if (sourceUri == destUri || sourceUri == dest.absolutePath) {
|
||||||
if (!dest.isFile || dest.length() == 0L) {
|
if (!isStagedSourceReady(instanceId, clientMessageId, expectedSizeBytes)) {
|
||||||
val input = when {
|
throw OutboundFileUnavailableException("Staged source file is incomplete")
|
||||||
sourceUri.startsWith("content://") || sourceUri.startsWith("file://") ->
|
}
|
||||||
UtilsLibrary.context.contentResolver.openInputStream(Uri.parse(sourceUri))
|
return@withContext StagedOutboundFile(uri = destUri, sizeBytes = dest.length())
|
||||||
else -> File(sourceUri).inputStream()
|
}
|
||||||
} ?: throw OutboundFileUnavailableException("Failed to read file from URI")
|
if (isStagedSourceReady(instanceId, clientMessageId, expectedSizeBytes)) {
|
||||||
input.use { inputStream ->
|
return@withContext StagedOutboundFile(uri = destUri, sizeBytes = dest.length())
|
||||||
dest.outputStream().use { output -> inputStream.copyTo(output) }
|
}
|
||||||
}
|
val part = sourcePartFile(instanceId, clientMessageId)
|
||||||
|
dest.delete()
|
||||||
|
sourceOkFile(instanceId, clientMessageId).delete()
|
||||||
|
part.delete()
|
||||||
|
val input = when {
|
||||||
|
sourceUri.startsWith("content://") || sourceUri.startsWith("file://") ->
|
||||||
|
UtilsLibrary.context.contentResolver.openInputStream(Uri.parse(sourceUri))
|
||||||
|
else -> File(sourceUri).takeIf { it.isFile }?.inputStream()
|
||||||
|
} ?: throw OutboundFileUnavailableException("Failed to read file from URI")
|
||||||
|
input.use { inputStream ->
|
||||||
|
FileOutputStream(part).use { output ->
|
||||||
|
inputStream.copyTo(output)
|
||||||
|
output.flush()
|
||||||
|
output.fd.sync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
StagedOutboundFile(uri = destUri, sizeBytes = dest.length().coerceAtLeast(0L))
|
atomicReplace(part, dest)
|
||||||
|
val stagedBytes = dest.length()
|
||||||
|
val expected = expectedSizeBytes.takeIf { it > 0L } ?: stagedBytes
|
||||||
|
if (expectedSizeBytes > 0L && stagedBytes != expectedSizeBytes) {
|
||||||
|
dest.delete()
|
||||||
|
sourceOkFile(instanceId, clientMessageId).delete()
|
||||||
|
throw OutboundFileUnavailableException("Staged file size mismatch")
|
||||||
|
}
|
||||||
|
writeOkMarker(sourceOkFile(instanceId, clientMessageId), stagedBytes, expected)
|
||||||
|
StagedOutboundFile(uri = destUri, sizeBytes = stagedBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual suspend fun isStagedSourceReady(
|
||||||
|
instanceId: String,
|
||||||
|
clientMessageId: String,
|
||||||
|
expectedSizeBytes: Long,
|
||||||
|
): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
readOkMarker(
|
||||||
|
sourceOkFile(instanceId, clientMessageId),
|
||||||
|
sourceFile(instanceId, clientMessageId),
|
||||||
|
expectedSizeBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun isEncryptedBlobReady(
|
||||||
|
instanceId: String,
|
||||||
|
clientMessageId: String,
|
||||||
|
expectedEncryptedSizeBytes: Long?,
|
||||||
|
): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val expected = expectedEncryptedSizeBytes?.takeIf { it > 0L } ?: 0L
|
||||||
|
val enc = blobFile(instanceId, clientMessageId)
|
||||||
|
val ok = blobOkFile(instanceId, clientMessageId)
|
||||||
|
if (!readOkMarker(ok, enc, expected)) return@withContext false
|
||||||
|
cipherFile(instanceId, clientMessageId).isFile
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun commitEncryptedUploadBlob(
|
||||||
|
instanceId: String,
|
||||||
|
clientMessageId: String,
|
||||||
|
encryptedSizeBytes: Long,
|
||||||
|
): Unit = withContext(Dispatchers.IO) {
|
||||||
|
val part = blobPartFile(instanceId, clientMessageId)
|
||||||
|
val final = blobFile(instanceId, clientMessageId)
|
||||||
|
if (part.isFile) {
|
||||||
|
atomicReplace(part, final)
|
||||||
|
} else if (!final.isFile) {
|
||||||
|
error("Encrypted upload blob missing")
|
||||||
|
}
|
||||||
|
if (final.length() != encryptedSizeBytes) {
|
||||||
|
throw OutboundFileUnavailableException("Encrypted blob size mismatch after commit")
|
||||||
|
}
|
||||||
|
writeOkMarker(blobOkFile(instanceId, clientMessageId), encryptedSizeBytes, encryptedSizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun repairInterruptedUploadArtifacts(
|
||||||
|
instanceId: String,
|
||||||
|
clientMessageId: String,
|
||||||
|
): Unit = withContext(Dispatchers.IO) {
|
||||||
|
sourcePartFile(instanceId, clientMessageId).delete()
|
||||||
|
blobPartFile(instanceId, clientMessageId).delete()
|
||||||
|
cipherPartFile(instanceId, clientMessageId).delete()
|
||||||
|
val enc = blobFile(instanceId, clientMessageId)
|
||||||
|
val encOk = blobOkFile(instanceId, clientMessageId)
|
||||||
|
if (!readOkMarker(encOk, enc, 0L)) {
|
||||||
|
enc.delete()
|
||||||
|
encOk.delete()
|
||||||
|
cipherFile(instanceId, clientMessageId).delete()
|
||||||
|
}
|
||||||
|
val source = sourceFile(instanceId, clientMessageId)
|
||||||
|
val sourceOk = sourceOkFile(instanceId, clientMessageId)
|
||||||
|
if (source.isFile && !readOkMarker(sourceOk, source, 0L)) {
|
||||||
|
source.delete()
|
||||||
|
sourceOk.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AndroidOutboundFileInputStream(
|
||||||
|
private val input: java.io.InputStream,
|
||||||
|
) : OutboundFileInputStream {
|
||||||
|
override suspend fun read(buffer: ByteArray, offset: Int, length: Int): Int =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
input.read(buffer, offset, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun close() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
input.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun openOutboundFileInputStream(fileUri: String): OutboundFileInputStream? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val stream = when {
|
||||||
|
fileUri.startsWith("content://") || fileUri.startsWith("file://") ->
|
||||||
|
UtilsLibrary.context.contentResolver.openInputStream(Uri.parse(fileUri))
|
||||||
|
else -> {
|
||||||
|
val file = File(fileUri)
|
||||||
|
if (!file.isFile) return@withContext null
|
||||||
|
file.inputStream()
|
||||||
|
}
|
||||||
|
} ?: return@withContext null
|
||||||
|
AndroidOutboundFileInputStream(stream)
|
||||||
|
}
|
||||||
|
|
||||||
actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
|
actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
when {
|
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) {
|
actual suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray) {
|
||||||
withContext(Dispatchers.IO) {
|
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? =
|
actual suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
if (!isEncryptedBlobReady(instanceId, clientMessageId, null)) return@withContext null
|
||||||
val f = blobFile(instanceId, clientMessageId)
|
val f = blobFile(instanceId, clientMessageId)
|
||||||
if (!f.isFile || f.length() == 0L) null else f.readBytes()
|
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) {
|
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? =
|
actual suspend fun loadUploadTransportCipherJson(instanceId: String, clientMessageId: String): String? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val f = cipherFile(instanceId, clientMessageId)
|
if (!cipherFile(instanceId, clientMessageId).isFile) return@withContext null
|
||||||
if (!f.isFile) null else f.readText().takeIf { it.isNotBlank() }
|
cipherFile(instanceId, clientMessageId).readText().takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String) {
|
actual suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
blobFile(instanceId, clientMessageId).delete()
|
|
||||||
cipherFile(instanceId, clientMessageId).delete()
|
|
||||||
sourceFile(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) {
|
actual suspend fun clearUploadSecretsOnly(instanceId: String, clientMessageId: String) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
blobFile(instanceId, clientMessageId).delete()
|
blobFile(instanceId, clientMessageId).delete()
|
||||||
|
blobPartFile(instanceId, clientMessageId).delete()
|
||||||
|
blobOkFile(instanceId, clientMessageId).delete()
|
||||||
cipherFile(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.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec)
|
||||||
cipher.doFinal(ciphertext)
|
cipher.doFinal(ciphertext)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ actual object DmCrypto {
|
|||||||
decryptAesGcmRaw(iv, ciphertext, mek)
|
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 {
|
private suspend fun decryptAesGcmRaw(iv: ByteArray, ciphertext: ByteArray, mek: ByteArray): ByteArray {
|
||||||
require(mek.size == AES_KEY_SIZE) { "MEK must be 32 bytes" }
|
require(mek.size == AES_KEY_SIZE) { "MEK must be 32 bytes" }
|
||||||
require(iv.size == GCM_IV_SIZE) { "IV must be 12 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) {
|
suspend fun persistExportUriPermission(exportUri: String) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (!exportUri.startsWith("content://")) return@withContext
|
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
|
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.content.Intent
|
||||||
import android.net.Uri
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import ru.fromchat.core.Logger
|
||||||
|
import ru.fromchat.core.files.AttachmentFileProvider
|
||||||
|
|
||||||
actual suspend fun writeBytesToExportUri(exportUri: String, bytes: ByteArray): Boolean =
|
actual suspend fun openCachedAttachmentFile(
|
||||||
withContext(Dispatchers.IO) {
|
cacheUri: String,
|
||||||
runCatching {
|
mimeType: String,
|
||||||
val uri = Uri.parse(exportUri)
|
displayFilename: String?,
|
||||||
UtilsLibrary.context.contentResolver.openOutputStream(uri, "wt")?.use { out ->
|
): Boolean = withContext(Dispatchers.Main) {
|
||||||
out.write(bytes)
|
val tag = "AttachmentOpen"
|
||||||
} != null
|
val appContext = UtilsLibrary.context
|
||||||
}.getOrDefault(false)
|
val context = findActivity(appContext) ?: appContext
|
||||||
}
|
val file = uriToLocalCacheFile(cacheUri) ?: return@withContext false
|
||||||
|
if (!file.exists() || file.length() <= 0L) return@withContext false
|
||||||
|
val contentUri = AttachmentFileProvider.uriForFile(appContext, file) ?: return@withContext false
|
||||||
|
val nameForMime = displayFilename?.takeIf { it.isNotBlank() }
|
||||||
|
?: AttachmentFileProvider.displayNameFor(file)
|
||||||
|
val resolvedMime = mimeType.takeIf { it.isNotBlank() && mimeType != "application/octet-stream" }
|
||||||
|
?: mimeTypeForFilename(nameForMime)
|
||||||
|
|
||||||
actual suspend fun isExportUriAccessible(exportUri: String): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
runCatching {
|
runCatching {
|
||||||
when {
|
fun commonFlags(intent: Intent): Intent = intent.apply {
|
||||||
exportUri.startsWith("content://") -> {
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
UtilsLibrary.context.contentResolver
|
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
.openFileDescriptor(Uri.parse(exportUri), "r")
|
clipData = ClipData.newRawUri(nameForMime, contentUri)
|
||||||
?.use { true } == true
|
if (context !is Activity) {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
exportUri.startsWith("file://") -> {
|
|
||||||
val path = Uri.parse(exportUri).path ?: return@runCatching false
|
|
||||||
File(path).isFile
|
|
||||||
}
|
|
||||||
else -> File(exportUri).isFile
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun buildViewIntent(type: String): Intent =
|
||||||
|
Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(contentUri, type)
|
||||||
|
}.let(::commonFlags)
|
||||||
|
|
||||||
|
fun queryHandlerPackages(pm: PackageManager, intent: Intent): List<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)
|
}.getOrDefault(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun openExportUri(exportUri: String, mimeType: String): Boolean {
|
private tailrec fun findActivity(ctx: Context?): Activity? = when (ctx) {
|
||||||
val context = UtilsLibrary.context
|
is Activity -> ctx
|
||||||
val uri = Uri.parse(
|
is ContextWrapper -> findActivity(ctx.baseContext)
|
||||||
when {
|
else -> null
|
||||||
exportUri.startsWith("content://") || exportUri.startsWith("file://") -> exportUri
|
|
||||||
else -> "file://$exportUri"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (uri.scheme == "file") {
|
|
||||||
val path = uri.path ?: return false
|
|
||||||
if (!File(path).isFile) return false
|
|
||||||
}
|
|
||||||
return runCatching {
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, mimeType)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
context.startActivity(Intent.createChooser(intent, null))
|
|
||||||
true
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_replying_to">Ответ %1$s</string>
|
||||||
<string name="message_corrupted_short">Сообщение не показывается</string>
|
<string name="message_corrupted_short">Сообщение не показывается</string>
|
||||||
<string name="attachment_image_load_failed">Не удалось загрузить</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_retry">Повторить</string>
|
||||||
|
<string name="attachment_open_failed">Не удалось открыть файл. Попробуйте «Сохранить» в меню сообщения.</string>
|
||||||
|
<string name="attachment_open_chooser_title">Открыть с помощью</string>
|
||||||
<string name="cd_attachment_retry">Повторить загрузку изображения</string>
|
<string name="cd_attachment_retry">Повторить загрузку изображения</string>
|
||||||
<string name="message_editing_title">Правка сообщения</string>
|
<string name="message_editing_title">Правка сообщения</string>
|
||||||
<string name="action_reply">Ответить</string>
|
<string name="action_reply">Ответить</string>
|
||||||
@@ -84,6 +89,14 @@
|
|||||||
<string name="action_cancel_send">Отменить</string>
|
<string name="action_cancel_send">Отменить</string>
|
||||||
<string name="notif_media_upload_percent">%1$d\u0025</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_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="action_save">Сохранить</string>
|
||||||
<string name="cd_close">Закрыть</string>
|
<string name="cd_close">Закрыть</string>
|
||||||
<string name="cd_remove">Убрать</string>
|
<string name="cd_remove">Убрать</string>
|
||||||
|
|||||||
@@ -87,7 +87,12 @@
|
|||||||
<string name="message_replying_to">Reply to %1$s</string>
|
<string name="message_replying_to">Reply to %1$s</string>
|
||||||
<string name="message_corrupted_short">Can’t show this message</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_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_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="cd_attachment_retry">Retry loading image</string>
|
||||||
<string name="message_editing_title">Edit message</string>
|
<string name="message_editing_title">Edit message</string>
|
||||||
|
|
||||||
@@ -100,6 +105,14 @@
|
|||||||
<string name="action_save">Save</string>
|
<string name="action_save">Save</string>
|
||||||
<string name="notif_media_upload_percent">%1$d\u0025</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_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 -->
|
<!-- Chat input -->
|
||||||
<string name="cd_close">Close</string>
|
<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.bearerAuth
|
||||||
import io.ktor.client.request.delete
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.bodyAsChannel
|
|
||||||
import io.ktor.http.HttpHeaders
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.http.HttpStatusCode
|
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 com.pr0gramm3r101.utils.files.PlatformFileSystem
|
||||||
import io.ktor.client.request.patch
|
import io.ktor.client.request.patch
|
||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
@@ -586,105 +581,97 @@ object ApiClient {
|
|||||||
else -> "${Config.apiBaseUrl}$path"
|
else -> "${Config.apiBaseUrl}$path"
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchEncryptedFile(path: String): ByteArray =
|
/** Encrypted ciphertext stored on disk after a resumable download. */
|
||||||
fetchEncryptedFileResumable(path, resumeKey = null, onProgress = null)
|
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(
|
suspend fun fetchEncryptedFileResumable(
|
||||||
path: String,
|
path: String,
|
||||||
resumeKey: String?,
|
resumeKey: String?,
|
||||||
onProgress: ((percent: Int) -> Unit)?,
|
onProgress: ((percent: Int) -> Unit)?,
|
||||||
): ByteArray {
|
): EncryptedFileOnDisk {
|
||||||
|
resumeKey?.let { anchorPartialDownloadMetaIfNeeded(it) }
|
||||||
val url = encryptedFileUrl(path)
|
val url = encryptedFileUrl(path)
|
||||||
val partialPath = resumeKey?.let { partialEncryptedDownloadPath(it) }
|
val outputPath = resumeKey?.let { partialEncryptedDownloadPath(it) }
|
||||||
val prefix = partialPath?.let { readPartialEncryptedBytes(it) } ?: ByteArray(0)
|
?: oneOffEncryptedDownloadPath()
|
||||||
val offset = prefix.size
|
?: error("Encrypted downloads directory unavailable")
|
||||||
|
val offset = if (PlatformFileSystem.exists(outputPath)) {
|
||||||
onProgress?.invoke(if (offset > 0) percentForBytes(offset, offset.coerceAtLeast(1)) else 1)
|
PlatformFileSystem.fileSize(outputPath)
|
||||||
|
} else {
|
||||||
val response = http.get(url) {
|
0L
|
||||||
if (offset > 0) {
|
|
||||||
header(HttpHeaders.Range, "bytes=$offset-")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (response.status) {
|
val resumePercent = if (offset > 0L) {
|
||||||
HttpStatusCode.PartialContent -> {
|
resumeKey?.let { loadPartialDownloadPercent(it) }
|
||||||
readDownloadBody(
|
?: percentForBytes(offset, offset.coerceAtLeast(1L))
|
||||||
response = response,
|
} else {
|
||||||
prefix = prefix,
|
1
|
||||||
partialPath = partialPath,
|
}
|
||||||
onProgress = onProgress,
|
var lastReportedPercent = -1
|
||||||
)
|
fun reportProgress(percent: Int) {
|
||||||
}
|
val pct = percent.coerceIn(0, 100)
|
||||||
HttpStatusCode.OK -> {
|
if (pct == lastReportedPercent && pct !in setOf(0, 100)) return
|
||||||
if (offset > 0) {
|
lastReportedPercent = pct
|
||||||
partialPath?.let { PlatformFileSystem.delete(it) }
|
onProgress?.invoke(pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
reportProgress(resumePercent.coerceIn(1, 99))
|
||||||
|
|
||||||
|
var expectedTotalBytes: Long? = null
|
||||||
|
val received = streamEncryptedFileToDisk(
|
||||||
|
url = url,
|
||||||
|
outputPath = outputPath,
|
||||||
|
rangeOffset = offset,
|
||||||
|
bearerToken = token,
|
||||||
|
userAgent = currentDownloadUserAgent(),
|
||||||
|
onChunkReceived = { receivedBytes, totalBytes ->
|
||||||
|
if (totalBytes != null && totalBytes > 0L) {
|
||||||
|
expectedTotalBytes = totalBytes
|
||||||
}
|
}
|
||||||
readDownloadBody(
|
val percent = if (totalBytes != null && totalBytes > 0L) {
|
||||||
response = response,
|
percentForBytes(receivedBytes, totalBytes)
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
(received / 32_768).coerceIn(1, 99)
|
(receivedBytes / 32_768L).toInt().coerceIn(1, 99)
|
||||||
},
|
}
|
||||||
)
|
resumeKey?.let {
|
||||||
}
|
savePartialDownloadProgress(
|
||||||
|
it,
|
||||||
|
percent,
|
||||||
|
totalBytes?.coerceAtMost(Int.MAX_VALUE.toLong())?.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reportProgress(percent)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
onProgress?.invoke(100)
|
expectedTotalBytes?.let { total ->
|
||||||
partialPath?.let { PlatformFileSystem.delete(it) }
|
if (total > 0L && received < total) {
|
||||||
return buffer
|
error("Encrypted download incomplete ($received of $total bytes): $path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reportProgress(99)
|
||||||
|
resumeKey?.let { clearPartialDownloadMeta(it) }
|
||||||
|
return EncryptedFileOnDisk(outputPath, received)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun responseTotalBytes(response: HttpResponse, receivedSoFar: Int): Int? {
|
private fun currentDownloadUserAgent(): String? {
|
||||||
val contentRange = response.headers[HttpHeaders.ContentRange]
|
val currentDevice = currentDeviceInfo()
|
||||||
if (contentRange != null) {
|
return buildLoginUserAgent(
|
||||||
val total = contentRange.substringAfterLast('/').toLongOrNull()
|
osName = currentDevice.osName?.takeIf { it.isNotBlank() },
|
||||||
if (total != null && total > 0L) return total.toInt()
|
osVersion = currentDevice.osVersion?.takeIf { it.isNotBlank() },
|
||||||
}
|
model = currentDevice.model?.takeIf { it.isNotBlank() },
|
||||||
val contentLength = response.contentLength()?.toInt()
|
brand = currentDevice.brand?.takeIf { it.isNotBlank() },
|
||||||
return when {
|
)
|
||||||
response.status == HttpStatusCode.PartialContent && contentLength != null ->
|
}
|
||||||
receivedSoFar + contentLength
|
|
||||||
contentLength != null && contentLength > 0 -> contentLength
|
private fun oneOffEncryptedDownloadPath(): String? {
|
||||||
else -> null
|
val dir = encryptedDownloadsDir() ?: return null
|
||||||
}
|
return "$dir/once_${kotlin.random.Random.nextLong()}.enc"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drops a partial encrypted download so the next attempt starts clean. */
|
/** Drops a partial encrypted download so the next attempt starts clean. */
|
||||||
@@ -692,26 +679,315 @@ object ApiClient {
|
|||||||
partialEncryptedDownloadPath(resumeKey)?.let { path ->
|
partialEncryptedDownloadPath(resumeKey)?.let { path ->
|
||||||
runCatching { PlatformFileSystem.delete(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()
|
val base = PlatformFileSystem.getAppCacheDirectory()
|
||||||
if (base.isEmpty()) return null
|
if (base.isEmpty()) return null
|
||||||
val dir = "$base/encrypted_downloads"
|
val dir = "$base/encrypted_downloads"
|
||||||
PlatformFileSystem.ensureDirectory(dir)
|
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._-]"), "_")
|
val safe = resumeKey.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
return "$dir/partial_$safe.enc"
|
return "$dir/partial_$safe.enc"
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun readPartialEncryptedBytes(path: String): ByteArray? {
|
private fun partialDownloadMetaPath(resumeKey: String): String? {
|
||||||
if (!PlatformFileSystem.exists(path)) return null
|
val dir = encryptedDownloadsDir() ?: return null
|
||||||
return runCatching {
|
val safe = resumeKey.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
ru.fromchat.core.cache.readOutboundFileBytes("file://$path")
|
return "$dir/partial_$safe.meta"
|
||||||
}.getOrNull()?.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun percentForBytes(received: Int, total: Int): Int {
|
private fun percentForBytes(received: Long, total: Long): Int {
|
||||||
if (received <= 0 || total <= 0) return 0
|
if (received <= 0L || total <= 0L) return 0
|
||||||
val raw = ((received.toDouble() / total.toDouble()) * 100.0).toInt()
|
val raw = ((received.toDouble() / total.toDouble()) * 100.0).toInt()
|
||||||
return when {
|
return when {
|
||||||
raw <= 0 -> 1
|
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.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.api.db.MessageCacheStore
|
||||||
import ru.fromchat.ui.chat.AttachmentMediaLog
|
import ru.fromchat.ui.chat.AttachmentMediaLog
|
||||||
|
import ru.fromchat.ui.chat.DecryptedFileCache
|
||||||
import ru.fromchat.ui.chat.DecryptedImageCache
|
import ru.fromchat.ui.chat.DecryptedImageCache
|
||||||
|
import ru.fromchat.ui.chat.DmFileDownloader
|
||||||
import ru.fromchat.ui.chat.DownloadedFileRegistry
|
import ru.fromchat.ui.chat.DownloadedFileRegistry
|
||||||
|
|
||||||
sealed class AttachmentDownloadProgress {
|
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.
|
* [progressPercentByKey] is the source of truth for UI; [progressFlow] is for one-shot side effects.
|
||||||
*/
|
*/
|
||||||
object AttachmentDownloadNotifier {
|
object AttachmentDownloadNotifier {
|
||||||
|
private var inFlightCheck: (String) -> Boolean = { false }
|
||||||
|
|
||||||
|
internal fun bindInFlightCheck(check: (String) -> Boolean) {
|
||||||
|
inFlightCheck = check
|
||||||
|
}
|
||||||
|
|
||||||
private val _progressFlow = MutableSharedFlow<AttachmentDownloadProgress>(extraBufferCapacity = 64)
|
private val _progressFlow = MutableSharedFlow<AttachmentDownloadProgress>(extraBufferCapacity = 64)
|
||||||
val progressFlow: SharedFlow<AttachmentDownloadProgress> = _progressFlow
|
val progressFlow: SharedFlow<AttachmentDownloadProgress> = _progressFlow
|
||||||
|
|
||||||
@@ -37,7 +46,12 @@ object AttachmentDownloadNotifier {
|
|||||||
private val _failedKeys = MutableStateFlow<Set<String>>(emptySet())
|
private val _failedKeys = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val failedKeys: StateFlow<Set<String>> = _failedKeys.asStateFlow()
|
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 mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private val resumeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val progressThrottleByKey = mutableMapOf<String, DownloadProgressThrottle>()
|
||||||
|
|
||||||
fun emit(
|
fun emit(
|
||||||
progress: AttachmentDownloadProgress,
|
progress: AttachmentDownloadProgress,
|
||||||
@@ -53,24 +67,15 @@ object AttachmentDownloadNotifier {
|
|||||||
is AttachmentDownloadProgress.Success -> progress.storageKey
|
is AttachmentDownloadProgress.Success -> progress.storageKey
|
||||||
is AttachmentDownloadProgress.Failed -> progress.storageKey
|
is AttachmentDownloadProgress.Failed -> progress.storageKey
|
||||||
}
|
}
|
||||||
val mirrorKeys = when {
|
val mirrorKeys = mirrorKeysFor(
|
||||||
mirrorAsFileAttachment || primaryKey.startsWith("file_") ->
|
primaryKey = primaryKey,
|
||||||
DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
|
messageId = messageId,
|
||||||
else ->
|
fileIndex = fileIndex,
|
||||||
DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
|
clientMessageId = clientMessageId,
|
||||||
}.ifEmpty { listOf(primaryKey) }
|
mirrorAsFileAttachment = mirrorAsFileAttachment,
|
||||||
|
)
|
||||||
when (progress) {
|
when (progress) {
|
||||||
is AttachmentDownloadProgress.InProgress -> {
|
is AttachmentDownloadProgress.InProgress -> Unit
|
||||||
if (progress.percent == 1 || progress.percent % 15 == 0 || progress.percent >= 95) {
|
|
||||||
AttachmentMediaLog.download(
|
|
||||||
"progress",
|
|
||||||
"key" to progress.storageKey,
|
|
||||||
"pct" to progress.percent,
|
|
||||||
"msg" to msg,
|
|
||||||
"mirror" to mirrorKeys.joinToString(","),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is AttachmentDownloadProgress.Success ->
|
is AttachmentDownloadProgress.Success ->
|
||||||
AttachmentMediaLog.download(
|
AttachmentMediaLog.download(
|
||||||
"success",
|
"success",
|
||||||
@@ -89,16 +94,47 @@ object AttachmentDownloadNotifier {
|
|||||||
when (progress) {
|
when (progress) {
|
||||||
is AttachmentDownloadProgress.InProgress -> {
|
is AttachmentDownloadProgress.InProgress -> {
|
||||||
val pct = progress.percent.coerceIn(1, 100)
|
val pct = progress.percent.coerceIn(1, 100)
|
||||||
_progressPercentByKey.update { map ->
|
val throttle = throttleFor(mirrorKeys)
|
||||||
map + mirrorKeys.associateWith { pct }
|
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 -> {
|
is AttachmentDownloadProgress.Success -> {
|
||||||
_progressPercentByKey.update { map ->
|
mirrorKeys.forEach { progressThrottleByKey.remove(it) }
|
||||||
map + mirrorKeys.associateWith { 100 }
|
_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 -> {
|
is AttachmentDownloadProgress.Failed -> {
|
||||||
|
mirrorKeys.forEach { progressThrottleByKey.remove(it) }
|
||||||
_progressPercentByKey.update { map -> map - mirrorKeys.toSet() }
|
_progressPercentByKey.update { map -> map - mirrorKeys.toSet() }
|
||||||
_failedKeys.update { keys -> keys + mirrorKeys.toSet() }
|
_failedKeys.update { keys -> keys + mirrorKeys.toSet() }
|
||||||
}
|
}
|
||||||
@@ -114,13 +150,185 @@ object AttachmentDownloadNotifier {
|
|||||||
clientMessageId: String? = null,
|
clientMessageId: String? = null,
|
||||||
mirrorAsFileAttachment: Boolean = false,
|
mirrorAsFileAttachment: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val keys = if (mirrorAsFileAttachment) {
|
val keys = lookupKeys(messageId, fileIndex, clientMessageId, mirrorAsFileAttachment).toSet()
|
||||||
DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
|
|
||||||
} else {
|
|
||||||
DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
|
|
||||||
}.toSet()
|
|
||||||
_progressPercentByKey.update { map -> map - keys }
|
_progressPercentByKey.update { map -> map - keys }
|
||||||
_failedKeys.update { failed -> failed - keys }
|
_failedKeys.update { failed -> failed - keys }
|
||||||
|
_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)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isFailed(
|
fun isFailed(
|
||||||
@@ -129,11 +337,33 @@ object AttachmentDownloadNotifier {
|
|||||||
clientMessageId: String? = null,
|
clientMessageId: String? = null,
|
||||||
mirrorAsFileAttachment: Boolean = false,
|
mirrorAsFileAttachment: Boolean = false,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val keys = if (mirrorAsFileAttachment) {
|
val keys = lookupKeys(messageId, fileIndex, clientMessageId, mirrorAsFileAttachment)
|
||||||
DownloadedFileRegistry.progressLookupKeys(messageId, fileIndex, clientMessageId)
|
|
||||||
} else {
|
|
||||||
DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId)
|
|
||||||
}
|
|
||||||
return keys.any { it in _failedKeys.value }
|
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,
|
val uploadJobId: String? = null,
|
||||||
/** For optimistic UI: 0-100 upload progress, null when complete. */
|
/** For optimistic UI: 0-100 upload progress, null when complete. */
|
||||||
val uploadProgress: Int? = null,
|
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. */
|
/** For DM file decryption; not serialized over network. */
|
||||||
@kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null,
|
@kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null,
|
||||||
/** Blurhashes for image files (by index); from decrypted message JSON. */
|
/** 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("pendingFileUri") val pendingFileUri: String? = null,
|
||||||
@SerialName("pendingFilename") val pendingFilename: String? = null,
|
@SerialName("pendingFilename") val pendingFilename: String? = null,
|
||||||
@SerialName("uploadJobId") val uploadJobId: String? = null,
|
@SerialName("uploadJobId") val uploadJobId: String? = null,
|
||||||
|
@SerialName("fileSizes") val fileSizes: List<Long>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -62,6 +63,7 @@ fun encodeOptimisticOutboundMessage(message: Message): String {
|
|||||||
pendingFileUri = pendingUri,
|
pendingFileUri = pendingUri,
|
||||||
pendingFilename = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() },
|
pendingFilename = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
uploadJobId = message.uploadJobId?.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() },
|
pendingFileUri = payload.pendingFileUri?.takeIf { it.isNotBlank() },
|
||||||
pendingFilename = payload.pendingFilename?.takeIf { it.isNotBlank() },
|
pendingFilename = payload.pendingFilename?.takeIf { it.isNotBlank() },
|
||||||
uploadJobId = payload.uploadJobId?.takeIf { it.isNotBlank() },
|
uploadJobId = payload.uploadJobId?.takeIf { it.isNotBlank() },
|
||||||
|
fileSizes = payload.fileSizes,
|
||||||
)
|
)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
ParsedDmMessageContent(text = plaintext)
|
ParsedDmMessageContent(text = plaintext)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ru.fromchat.api.DmConversation
|
|||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
import ru.fromchat.api.sortMessagesForChatDisplay
|
import ru.fromchat.api.sortMessagesForChatDisplay
|
||||||
import ru.fromchat.ui.chat.DecryptedImageCache
|
import ru.fromchat.ui.chat.DecryptedImageCache
|
||||||
|
import ru.fromchat.ui.chat.DownloadedFileRegistry
|
||||||
import ru.fromchat.ui.chat.dedupeMessagesByClientId
|
import ru.fromchat.ui.chat.dedupeMessagesByClientId
|
||||||
import ru.fromchat.ui.chat.dropSupersededOptimisticMessages
|
import ru.fromchat.ui.chat.dropSupersededOptimisticMessages
|
||||||
import ru.fromchat.api.ProfileCache
|
import ru.fromchat.api.ProfileCache
|
||||||
@@ -472,6 +473,8 @@ object MessageCacheStore {
|
|||||||
?: msg.pendingFileAspectRatio,
|
?: msg.pendingFileAspectRatio,
|
||||||
uploadJobId = cid,
|
uploadJobId = cid,
|
||||||
uploadProgress = percent,
|
uploadProgress = percent,
|
||||||
|
fileSizes = msg.fileSizes
|
||||||
|
?: payload.fileSizeBytes.takeIf { it > 0L }?.let { listOf(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -538,6 +541,7 @@ object MessageCacheStore {
|
|||||||
?: resolveLocalPreviewUri(base),
|
?: resolveLocalPreviewUri(base),
|
||||||
pendingFilename = parsed.pendingFilename ?: base.pendingFilename,
|
pendingFilename = parsed.pendingFilename ?: base.pendingFilename,
|
||||||
uploadJobId = parsed.uploadJobId ?: base.uploadJobId,
|
uploadJobId = parsed.uploadJobId ?: base.uploadJobId,
|
||||||
|
fileSizes = parsed.fileSizes ?: base.fileSizes,
|
||||||
pendingFileAspectRatio = parsed.fileAspectRatios?.firstOrNull()
|
pendingFileAspectRatio = parsed.fileAspectRatios?.firstOrNull()
|
||||||
?: parsed.fileDimensions?.firstOrNull()?.let { (w, h) ->
|
?: parsed.fileDimensions?.firstOrNull()?.let { (w, h) ->
|
||||||
aspectRatioFromDimensionPair(w, h)
|
aspectRatioFromDimensionPair(w, h)
|
||||||
@@ -607,4 +611,69 @@ object MessageCacheStore {
|
|||||||
.executeAsOneOrNull() != null
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+243
-77
@@ -15,19 +15,34 @@ import ru.fromchat.api.db.MessageCacheStore
|
|||||||
import ru.fromchat.api.db.MessageDatabaseProvider
|
import ru.fromchat.api.db.MessageDatabaseProvider
|
||||||
import ru.fromchat.api.db.conversationIdForDm
|
import ru.fromchat.api.db.conversationIdForDm
|
||||||
import ru.fromchat.core.cache.OutboundFileUnavailableException
|
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.core.cache.clearUploadSecretsOnly
|
||||||
import ru.fromchat.api.optimisticMessageIdForClientMessageId
|
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.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.loadEncryptedUploadBlob
|
||||||
import ru.fromchat.core.cache.loadUploadTransportCipherJson
|
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.readOutboundFileBytes
|
||||||
|
import ru.fromchat.core.cache.repairInterruptedUploadArtifacts
|
||||||
import ru.fromchat.core.cache.saveEncryptedUploadBlob
|
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.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.TransportCiphertext
|
||||||
import ru.fromchat.crypto.transport.TransportCrypto
|
import ru.fromchat.crypto.transport.TransportCrypto
|
||||||
|
import ru.fromchat.crypto.transport.TransportFileEncryptor
|
||||||
import ru.fromchat.db.Outbox
|
import ru.fromchat.db.Outbox
|
||||||
|
|
||||||
private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024
|
private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024
|
||||||
@@ -85,12 +100,22 @@ object DmAttachmentOutboxHandler {
|
|||||||
|
|
||||||
val serverUploadId = arrayOf(payload.uploadId.trim())
|
val serverUploadId = arrayOf(payload.uploadId.trim())
|
||||||
return runCatching {
|
return runCatching {
|
||||||
|
repairInterruptedUploadArtifacts(instanceId, clientMessageId)
|
||||||
ensureStillQueued(instanceId, clientMessageId)
|
ensureStillQueued(instanceId, clientMessageId)
|
||||||
AttachmentUploadNotifier.emit(
|
AttachmentUploadNotifier.emit(
|
||||||
AttachmentUploadProgress.Pending(clientMessageId, payload.filename),
|
AttachmentUploadProgress.Pending(clientMessageId, payload.filename),
|
||||||
messageLabel = payload.plaintext,
|
messageLabel = payload.plaintext,
|
||||||
)
|
)
|
||||||
val stagedPayload = ensureStagedPayload(instanceId, row, payload)
|
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)
|
ensureStillQueued(instanceId, clientMessageId)
|
||||||
val restoredPercent = uploadPercent(row.bytesUploaded, stagedPayload)
|
val restoredPercent = uploadPercent(row.bytesUploaded, stagedPayload)
|
||||||
if (restoredPercent > 0) {
|
if (restoredPercent > 0) {
|
||||||
@@ -99,55 +124,50 @@ object DmAttachmentOutboxHandler {
|
|||||||
emitProgress(clientMessageId, 0, stagedPayload.filename, stagedPayload.plaintext)
|
emitProgress(clientMessageId, 0, stagedPayload.filename, stagedPayload.plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
val prepared = loadPrepared(instanceId, clientMessageId)
|
|
||||||
val encryptedBlob: ByteArray
|
|
||||||
val msgCipher: TransportCiphertext
|
|
||||||
var activePayload = stagedPayload
|
var activePayload = stagedPayload
|
||||||
|
val expectedEncryptedSize = activePayload.encryptedFileSizeBytes.takeIf { it > 0L }
|
||||||
if (prepared != null) {
|
val preparedCipher = loadPreparedCipher(
|
||||||
encryptedBlob = prepared.first
|
instanceId,
|
||||||
msgCipher = prepared.second
|
clientMessageId,
|
||||||
if (activePayload.encryptedFileSizeBytes <= 0L) {
|
expectedEncryptedSize,
|
||||||
activePayload = activePayload.copy(encryptedFileSizeBytes = encryptedBlob.size.toLong())
|
)
|
||||||
}
|
var encryptedSize = encryptedUploadBlobSizeBytes(instanceId, clientMessageId)
|
||||||
|
?.takeIf { it > 0L }
|
||||||
|
?: expectedEncryptedSize
|
||||||
|
val msgCipher: TransportCiphertext
|
||||||
|
if (preparedCipher != null && encryptedSize != null) {
|
||||||
|
msgCipher = preparedCipher
|
||||||
} else {
|
} else {
|
||||||
ensureStillQueued(instanceId, clientMessageId)
|
clearPrepared(instanceId, clientMessageId)
|
||||||
val bytes = readOutboundFileBytes(stagedPayload.fileUri)
|
val encrypted = encryptAndPersistToDisk(
|
||||||
OutgoingMessageCoordinator.abortDmServerUploadIfNeeded(stagedPayload.uploadId)
|
instanceId = instanceId,
|
||||||
serverUploadId[0] = ""
|
row = row,
|
||||||
activePayload = stagedPayload.copy(uploadId = "", encryptedFileSizeBytes = 0L)
|
stagedPayload = stagedPayload,
|
||||||
persistPayloadProgress(instanceId, row, activePayload, bytesUploaded = 0L)
|
serverUploadId = serverUploadId,
|
||||||
ensureStillQueued(instanceId, clientMessageId)
|
|
||||||
val transportKey = ApiClient.getTransportPublicKey()
|
|
||||||
val (freshCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret(
|
|
||||||
plaintext = stagedPayload.plaintext,
|
|
||||||
transportPublicKeyB64 = transportKey.publicKeyB64,
|
|
||||||
)
|
)
|
||||||
try {
|
encryptedSize = encrypted.encryptedSize
|
||||||
val blob = TransportCrypto.encryptFileForTransport(
|
msgCipher = encrypted.cipher
|
||||||
fileBytes = bytes,
|
activePayload = encrypted.payload
|
||||||
transportPublicKeyB64 = transportKey.publicKeyB64,
|
}
|
||||||
ephemeralSecretKey = ephemeralSecret,
|
val blobSize = encryptedSize ?: return@runCatching false
|
||||||
)
|
if (activePayload.encryptedFileSizeBytes <= 0L) {
|
||||||
encryptedBlob = blob
|
activePayload = activePayload.copy(encryptedFileSizeBytes = blobSize)
|
||||||
msgCipher = freshCipher
|
}
|
||||||
savePrepared(instanceId, clientMessageId, encryptedBlob, msgCipher)
|
if (!isEncryptedBlobReady(instanceId, clientMessageId, blobSize)) {
|
||||||
activePayload = activePayload.copy(encryptedFileSizeBytes = encryptedBlob.size.toLong())
|
return@runCatching false
|
||||||
persistPayloadProgress(instanceId, row, activePayload, bytesUploaded = 0L)
|
|
||||||
} finally {
|
|
||||||
ephemeralSecret.fill(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureStillQueued(instanceId, clientMessageId)
|
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)
|
sendInline(activePayload, encryptedBlob, msgCipher)
|
||||||
} else {
|
} else {
|
||||||
sendResumable(
|
sendResumable(
|
||||||
instanceId = instanceId,
|
instanceId = instanceId,
|
||||||
row = row,
|
row = row,
|
||||||
payload = activePayload,
|
payload = activePayload,
|
||||||
encryptedBlob = encryptedBlob,
|
encryptedSize = blobSize,
|
||||||
msgCipher = msgCipher,
|
msgCipher = msgCipher,
|
||||||
serverUploadId = serverUploadId,
|
serverUploadId = serverUploadId,
|
||||||
)
|
)
|
||||||
@@ -186,18 +206,22 @@ object DmAttachmentOutboxHandler {
|
|||||||
messageLabel = payload.plaintext,
|
messageLabel = payload.plaintext,
|
||||||
)
|
)
|
||||||
runCatching {
|
runCatching {
|
||||||
clearOutboundImageCaches(
|
val optimisticId = optimisticMessageIdForClientMessageId(clientMessageId)
|
||||||
clientMessageId,
|
clearOutboundImageCaches(clientMessageId, optimisticId)
|
||||||
optimisticMessageIdForClientMessageId(clientMessageId),
|
clearOutboundFileCaches(clientMessageId, optimisticId)
|
||||||
)
|
|
||||||
OutgoingMessageCoordinator.cancelOutboundMessage(clientMessageId, row.conversationId)
|
OutgoingMessageCoordinator.cancelOutboundMessage(clientMessageId, row.conversationId)
|
||||||
}
|
}
|
||||||
return true
|
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(
|
AttachmentUploadNotifier.emit(
|
||||||
AttachmentUploadProgress.Failed(
|
AttachmentUploadProgress.Failed(
|
||||||
jobId = clientMessageId,
|
jobId = clientMessageId,
|
||||||
error = error.message ?: "Upload failed",
|
error = failureKey,
|
||||||
),
|
),
|
||||||
messageLabel = payload.plaintext,
|
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(
|
private suspend fun sendInline(
|
||||||
payload: DmAttachmentOutboxPayload,
|
payload: DmAttachmentOutboxPayload,
|
||||||
encryptedBlob: ByteArray,
|
encryptedBlob: ByteArray,
|
||||||
@@ -229,55 +348,93 @@ object DmAttachmentOutboxHandler {
|
|||||||
instanceId: String,
|
instanceId: String,
|
||||||
row: Outbox,
|
row: Outbox,
|
||||||
payload: DmAttachmentOutboxPayload,
|
payload: DmAttachmentOutboxPayload,
|
||||||
encryptedBlob: ByteArray,
|
encryptedSize: Long,
|
||||||
msgCipher: TransportCiphertext,
|
msgCipher: TransportCiphertext,
|
||||||
serverUploadId: Array<String>,
|
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 {
|
try {
|
||||||
if (uploadId.isEmpty()) {
|
if (uploadId.isEmpty()) {
|
||||||
val init = ApiClient.initDmUpload(
|
val init = ApiClient.initDmUpload(
|
||||||
filename = payload.filename,
|
filename = payload.filename,
|
||||||
totalSize = encryptedBlob.size.toLong(),
|
totalSize = encryptedSize,
|
||||||
recipientId = payload.recipientId,
|
recipientId = payload.recipientId,
|
||||||
chunkSize = DEFAULT_CHUNK_SIZE,
|
chunkSize = DEFAULT_CHUNK_SIZE,
|
||||||
)
|
)
|
||||||
uploadId = init.uploadId
|
uploadId = init.uploadId
|
||||||
serverUploadId[0] = uploadId
|
serverUploadId[0] = uploadId
|
||||||
persistPayloadProgress(instanceId, row, payload.copy(uploadId = uploadId), row.bytesUploaded)
|
persistPayloadProgress(instanceId, row, activePayload.copy(uploadId = uploadId), bytesUploaded)
|
||||||
} else {
|
} else {
|
||||||
serverUploadId[0] = uploadId
|
serverUploadId[0] = uploadId
|
||||||
}
|
}
|
||||||
|
|
||||||
var offset = row.bytesUploaded.toInt().coerceAtLeast(0)
|
val serverStatus = ApiClient.getDmUploadStatus(uploadId)
|
||||||
val serverOffset = ApiClient.getDmUploadStatus(uploadId).offset.toInt().coerceAtLeast(0)
|
val serverOffset = serverStatus.offset.coerceAtLeast(0L)
|
||||||
offset = maxOf(offset, serverOffset)
|
if (serverStatus.totalSize > 0L && serverStatus.totalSize != encryptedSize) {
|
||||||
while (offset < encryptedBlob.size) {
|
OutgoingMessageCoordinator.abortDmServerUploadIfNeeded(uploadId)
|
||||||
ensureStillQueued(instanceId, payload.clientMessageId)
|
uploadId = ""
|
||||||
val nextOffset = minOf(offset + DEFAULT_CHUNK_SIZE, encryptedBlob.size)
|
serverUploadId[0] = ""
|
||||||
val chunk = encryptedBlob.copyOfRange(offset, nextOffset)
|
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(
|
ApiClient.uploadDmChunk(
|
||||||
uploadId = uploadId,
|
uploadId = uploadId,
|
||||||
offset = offset.toLong(),
|
offset = offset,
|
||||||
dataB64 = Base64.encode(chunk),
|
dataB64 = Base64.encode(chunk),
|
||||||
)
|
)
|
||||||
offset = nextOffset
|
offset += chunk.size.toLong()
|
||||||
val percent = ((offset.toDouble() / encryptedBlob.size.toDouble()) * 100.0).toInt()
|
val percent = ((offset.toDouble() / encryptedSize.toDouble()) * 100.0).toInt()
|
||||||
emitProgress(payload.clientMessageId, percent, payload.filename, payload.plaintext)
|
emitProgress(activePayload.clientMessageId, percent, activePayload.filename, activePayload.plaintext)
|
||||||
persistPayloadProgress(
|
persistPayloadProgress(
|
||||||
instanceId,
|
instanceId,
|
||||||
row,
|
row,
|
||||||
payload.copy(uploadId = uploadId),
|
activePayload.copy(uploadId = uploadId),
|
||||||
offset.toLong(),
|
offset,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val completed: DmUploadCompleteResponse = ApiClient.completeDmUpload(uploadId)
|
val completed: DmUploadCompleteResponse = ApiClient.completeDmUpload(uploadId)
|
||||||
ApiClient.sendDm(
|
ApiClient.sendDm(
|
||||||
recipientId = payload.recipientId,
|
recipientId = activePayload.recipientId,
|
||||||
plaintext = payload.plaintext,
|
plaintext = activePayload.plaintext,
|
||||||
clientMessageId = payload.clientMessageId,
|
clientMessageId = activePayload.clientMessageId,
|
||||||
replyToId = payload.replyToId,
|
replyToId = activePayload.replyToId,
|
||||||
uploadedFileIds = listOf(completed.fileId),
|
uploadedFileIds = listOf(completed.fileId),
|
||||||
preparedTransport = msgCipher,
|
preparedTransport = msgCipher,
|
||||||
)
|
)
|
||||||
@@ -294,14 +451,22 @@ object DmAttachmentOutboxHandler {
|
|||||||
row: Outbox,
|
row: Outbox,
|
||||||
payload: DmAttachmentOutboxPayload,
|
payload: DmAttachmentOutboxPayload,
|
||||||
): 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) {
|
if (staged.sizeBytes <= 0L) {
|
||||||
throw OutboundFileUnavailableException("Attachment file is empty or unavailable")
|
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)
|
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)
|
persistPayloadProgress(instanceId, row, updated, row.bytesUploaded)
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
@@ -328,20 +493,20 @@ object DmAttachmentOutboxHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadPrepared(
|
private suspend fun loadPreparedCipher(
|
||||||
instanceId: String,
|
instanceId: String,
|
||||||
clientMessageId: String,
|
clientMessageId: String,
|
||||||
): Pair<ByteArray, TransportCiphertext>? {
|
expectedEncryptedSizeBytes: Long?,
|
||||||
val blob = loadEncryptedUploadBlob(instanceId, clientMessageId) ?: return null
|
): TransportCiphertext? {
|
||||||
|
if (!isEncryptedBlobReady(instanceId, clientMessageId, expectedEncryptedSizeBytes)) return null
|
||||||
val raw = loadUploadTransportCipherJson(instanceId, clientMessageId) ?: return null
|
val raw = loadUploadTransportCipherJson(instanceId, clientMessageId) ?: return null
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val stored = json.decodeFromString<StoredTransportCipher>(raw)
|
val stored = json.decodeFromString<StoredTransportCipher>(raw)
|
||||||
val cipher = TransportCiphertext(
|
TransportCiphertext(
|
||||||
clientPublicKeyB64 = stored.clientPublicKeyB64,
|
clientPublicKeyB64 = stored.clientPublicKeyB64,
|
||||||
nonceB64 = stored.nonceB64,
|
nonceB64 = stored.nonceB64,
|
||||||
ciphertextB64 = stored.ciphertextB64,
|
ciphertextB64 = stored.ciphertextB64,
|
||||||
)
|
)
|
||||||
blob to cipher
|
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,13 +516,14 @@ object DmAttachmentOutboxHandler {
|
|||||||
blob: ByteArray,
|
blob: ByteArray,
|
||||||
cipher: TransportCiphertext,
|
cipher: TransportCiphertext,
|
||||||
) {
|
) {
|
||||||
saveEncryptedUploadBlob(instanceId, clientMessageId, blob)
|
|
||||||
val stored = StoredTransportCipher(
|
val stored = StoredTransportCipher(
|
||||||
clientPublicKeyB64 = cipher.clientPublicKeyB64,
|
clientPublicKeyB64 = cipher.clientPublicKeyB64,
|
||||||
nonceB64 = cipher.nonceB64,
|
nonceB64 = cipher.nonceB64,
|
||||||
ciphertextB64 = cipher.ciphertextB64,
|
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) {
|
private suspend fun clearPrepared(instanceId: String, clientMessageId: String) {
|
||||||
|
|||||||
+12
-1
@@ -113,6 +113,7 @@ object OutgoingMessageCoordinator {
|
|||||||
filename: String,
|
filename: String,
|
||||||
optimisticMessage: Message,
|
optimisticMessage: Message,
|
||||||
aspectRatio: Float? = null,
|
aspectRatio: Float? = null,
|
||||||
|
fileSizeBytes: Long = 0L,
|
||||||
) {
|
) {
|
||||||
val instanceId = CacheContext.requireActiveInstanceId()
|
val instanceId = CacheContext.requireActiveInstanceId()
|
||||||
val conversationId = conversationIdForDm(recipientId)
|
val conversationId = conversationIdForDm(recipientId)
|
||||||
@@ -134,7 +135,7 @@ object OutgoingMessageCoordinator {
|
|||||||
replyToId = replyToId,
|
replyToId = replyToId,
|
||||||
fileUri = fileUri,
|
fileUri = fileUri,
|
||||||
filename = filename,
|
filename = filename,
|
||||||
fileSizeBytes = 0L,
|
fileSizeBytes = fileSizeBytes.coerceAtLeast(0L),
|
||||||
aspectRatio = aspectRatio?.takeIf { it > 0f },
|
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. */
|
/** Drops a queued outbound row, local message, and any upload artifacts. */
|
||||||
suspend fun cancelOutboundMessage(clientMessageId: String, conversationId: String) {
|
suspend fun cancelOutboundMessage(clientMessageId: String, conversationId: String) {
|
||||||
val cid = clientMessageId.trim()
|
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,
|
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.
|
* 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(
|
expect suspend fun stageOutboundFileForUpload(
|
||||||
instanceId: String,
|
instanceId: String,
|
||||||
clientMessageId: String,
|
clientMessageId: String,
|
||||||
sourceUri: String,
|
sourceUri: String,
|
||||||
|
expectedSizeBytes: Long = 0L,
|
||||||
): StagedOutboundFile
|
): StagedOutboundFile
|
||||||
|
|
||||||
/** Reads the picked attachment from a platform URI string. */
|
/** Reads the picked attachment from a platform URI string. */
|
||||||
expect suspend fun readOutboundFileBytes(fileUri: String): ByteArray
|
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 saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray)
|
||||||
|
|
||||||
expect suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): 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)
|
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 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)
|
expect suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String)
|
||||||
|
|
||||||
/** Drops upload secrets and staging copy; keeps [DecryptedImageCache] files intact. */
|
/** 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
|
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.ApiClient
|
||||||
|
import ru.fromchat.api.AttachmentDownloadNotifier
|
||||||
import ru.fromchat.api.db.InstanceRegistryStore
|
import ru.fromchat.api.db.InstanceRegistryStore
|
||||||
import ru.fromchat.core.Settings
|
import ru.fromchat.core.Settings
|
||||||
import ru.fromchat.api.outbox.scheduleOutboxProcessing
|
import ru.fromchat.api.outbox.scheduleOutboxProcessing
|
||||||
import ru.fromchat.core.cache.CacheContext
|
import ru.fromchat.core.cache.CacheContext
|
||||||
import ru.fromchat.core.config.Config
|
|
||||||
|
|
||||||
sealed interface SessionBootstrapResult {
|
sealed interface SessionBootstrapResult {
|
||||||
data object Ready : SessionBootstrapResult
|
data object Ready : SessionBootstrapResult
|
||||||
@@ -13,17 +19,109 @@ sealed interface SessionBootstrapResult {
|
|||||||
data object LogoutRequired : 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
|
if (!hasToken) return SessionBootstrapResult.Ready
|
||||||
val config = Settings.serverConfig
|
val config = Settings.serverConfig
|
||||||
val apiBase = apiBaseUrlFor(config)
|
val apiBase = apiBaseUrlFor(config)
|
||||||
val resolve = resolveInstanceId(
|
val resolve = resolveInstanceId(
|
||||||
config = config,
|
config = config,
|
||||||
apiBaseUrl = apiBase,
|
apiBaseUrl = apiBase,
|
||||||
forceNetwork = true,
|
forceNetwork = forceNetwork,
|
||||||
)
|
)
|
||||||
return when (resolve) {
|
return when (resolve) {
|
||||||
is InstanceIdResolveResult.Cached,
|
is InstanceIdResolveResult.Cached,
|
||||||
@@ -35,8 +133,7 @@ suspend fun bootstrapSessionInstance(hasToken: Boolean): SessionBootstrapResult
|
|||||||
is InstanceIdResolveResult.Fetched -> resolve.instanceId
|
is InstanceIdResolveResult.Fetched -> resolve.instanceId
|
||||||
is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
|
is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
|
||||||
}
|
}
|
||||||
CacheContext.setActiveInstance(id, ApiClient.user?.id)
|
activateInstance(id)
|
||||||
scheduleOutboxProcessing(id)
|
|
||||||
SessionBootstrapResult.Ready
|
SessionBootstrapResult.Ready
|
||||||
}
|
}
|
||||||
InstanceIdResolveResult.Timeout,
|
InstanceIdResolveResult.Timeout,
|
||||||
@@ -44,8 +141,7 @@ suspend fun bootstrapSessionInstance(hasToken: Boolean): SessionBootstrapResult
|
|||||||
-> {
|
-> {
|
||||||
val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty()
|
val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty()
|
||||||
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||||
CacheContext.setActiveInstance(cached, ApiClient.user?.id)
|
activateInstance(cached)
|
||||||
scheduleOutboxProcessing(cached)
|
|
||||||
SessionBootstrapResult.OfflineCached
|
SessionBootstrapResult.OfflineCached
|
||||||
} else {
|
} else {
|
||||||
SessionBootstrapResult.OfflineCached
|
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,
|
file: ru.fromchat.api.DmFile,
|
||||||
envelope: DmEnvelope,
|
envelope: DmEnvelope,
|
||||||
currentUserId: Int?,
|
currentUserId: Int?,
|
||||||
|
outputPath: String,
|
||||||
downloadResumeKey: String? = null,
|
downloadResumeKey: String? = null,
|
||||||
onDownloadProgress: ((Int) -> Unit)? = null,
|
onDownloadProgress: ((Int) -> Unit)? = null,
|
||||||
): ByteArray {
|
): Long {
|
||||||
val wrappedMekB64 = file.wrappedMekB64
|
val wrappedMekB64 = file.wrappedMekB64
|
||||||
?: envelope.files?.find { it.path == file.path }?.wrappedMekB64
|
?: envelope.files?.find { it.path == file.path }?.wrappedMekB64
|
||||||
?: envelope.wrappedMekB64
|
?: envelope.wrappedMekB64
|
||||||
@@ -71,17 +72,28 @@ suspend fun decryptFile(
|
|||||||
|
|
||||||
val mek = unwrapMek(wrappedMekB64, envelope, currentUserId)
|
val mek = unwrapMek(wrappedMekB64, envelope, currentUserId)
|
||||||
ru.fromchat.core.Logger.d("DmCrypto", "fetchEncryptedFile path=${file.path}")
|
ru.fromchat.core.Logger.d("DmCrypto", "fetchEncryptedFile path=${file.path}")
|
||||||
val encryptedBytes = if (downloadResumeKey != null) {
|
val encryptedOnDisk = ru.fromchat.api.ApiClient.fetchEncryptedFileResumable(
|
||||||
ru.fromchat.api.ApiClient.fetchEncryptedFileResumable(
|
path = file.path,
|
||||||
path = file.path,
|
resumeKey = downloadResumeKey,
|
||||||
resumeKey = downloadResumeKey,
|
onProgress = onDownloadProgress,
|
||||||
onProgress = onDownloadProgress,
|
)
|
||||||
)
|
if (encryptedOnDisk.sizeBytes <= 0L) {
|
||||||
} else {
|
|
||||||
ru.fromchat.api.ApiClient.fetchEncryptedFile(file.path)
|
|
||||||
}
|
|
||||||
if (encryptedBytes.isEmpty()) {
|
|
||||||
throw IllegalArgumentException("Encrypted file is empty: ${file.path}")
|
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]). */
|
/** AES-GCM decrypt downloaded file bytes (ciphertext + tag; IV from [ivB64]). */
|
||||||
suspend fun decryptAesGcm(ivB64: String, ciphertext: ByteArray, mek: ByteArray): ByteArray
|
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.ImageLoader
|
||||||
import coil3.compose.setSingletonImageLoaderFactory
|
import coil3.compose.setSingletonImageLoaderFactory
|
||||||
import coil3.svg.SvgDecoder
|
import coil3.svg.SvgDecoder
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
@@ -52,6 +53,7 @@ import kotlinx.serialization.json.jsonObject
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import ru.fromchat.AppForeground
|
import ru.fromchat.AppForeground
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.AttachmentDownloadNotifier
|
||||||
import ru.fromchat.api.ProfileCache
|
import ru.fromchat.api.ProfileCache
|
||||||
import ru.fromchat.api.UpdateSyncManager
|
import ru.fromchat.api.UpdateSyncManager
|
||||||
import ru.fromchat.api.UserStatusStore
|
import ru.fromchat.api.UserStatusStore
|
||||||
@@ -189,49 +191,16 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var startDestination by remember { mutableStateOf<String?>(null) }
|
var startDestination by remember { mutableStateOf<String?>(null) }
|
||||||
|
var sessionLogoutRequired by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
runCatching {
|
kotlinx.coroutines.withContext(Dispatchers.Default) {
|
||||||
Config.initialize()
|
runCatching { Config.initialize() }
|
||||||
|
runCatching { ensureFromChatCacheGeneration() }
|
||||||
|
runCatching { NetworkConnectivity.ensureStarted() }
|
||||||
|
runCatching { ApiClient.loadPersistedData() }
|
||||||
}
|
}
|
||||||
|
|
||||||
runCatching { ensureFromChatCacheGeneration() }
|
|
||||||
|
|
||||||
runCatching { NetworkConnectivity.ensureStarted() }
|
|
||||||
|
|
||||||
// Load persisted token and user data
|
|
||||||
ApiClient.loadPersistedData()
|
|
||||||
|
|
||||||
val hasTokenForBootstrap = ApiClient.token?.isNotEmpty() == true
|
|
||||||
if (hasTokenForBootstrap) {
|
|
||||||
when (ru.fromchat.core.instance.bootstrapSessionInstance(hasToken = true)) {
|
|
||||||
ru.fromchat.core.instance.SessionBootstrapResult.LogoutRequired -> {
|
|
||||||
ru.fromchat.core.instance.logoutIfInstanceUnsupported()
|
|
||||||
startDestination = "login"
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
ru.fromchat.core.instance.SessionBootstrapResult.OfflineCached,
|
|
||||||
ru.fromchat.core.instance.SessionBootstrapResult.Ready,
|
|
||||||
-> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching { ProfileCache.hydrateFromDisk() }
|
|
||||||
|
|
||||||
val hasTokenInitially = ApiClient.token?.isNotEmpty() == true
|
|
||||||
if (hasTokenInitially) {
|
|
||||||
runCatching {
|
|
||||||
val ownProfile = ApiClient.getOwnProfile()
|
|
||||||
ApiClient.syncSuspensionStateFromProfile(ownProfile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize update sync state for the current user (if any)
|
|
||||||
runCatching {
|
|
||||||
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now determine start destination based on loaded token
|
|
||||||
val hasToken = ApiClient.token?.isNotEmpty() == true
|
val hasToken = ApiClient.token?.isNotEmpty() == true
|
||||||
startDestination = when {
|
startDestination = when {
|
||||||
hasToken && startAtDmConversationUserId != null -> "chat"
|
hasToken && startAtDmConversationUserId != null -> "chat"
|
||||||
@@ -239,6 +208,34 @@ fun App(
|
|||||||
hasToken && !startAtPublicChat -> "chat"
|
hasToken && !startAtPublicChat -> "chat"
|
||||||
else -> "login"
|
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]).
|
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]).
|
||||||
|
|||||||
+143
-35
@@ -1,22 +1,34 @@
|
|||||||
package ru.fromchat.ui.chat
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
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].
|
* Limits concurrent DM attachment decrypt/download work to [MAX_PARALLEL] across **different** keys.
|
||||||
* Additional requests wait in a priority queue (visible messages first).
|
* The same [storageKey] never runs more than one download at a time; duplicate callers share one result.
|
||||||
*/
|
*/
|
||||||
object AttachmentDownloadScheduler {
|
object AttachmentDownloadScheduler {
|
||||||
private const val MAX_PARALLEL = 2
|
private const val MAX_PARALLEL = 2
|
||||||
|
|
||||||
|
init {
|
||||||
|
AttachmentDownloadNotifier.bindInFlightCheck { storageKey -> isActive(storageKey) }
|
||||||
|
}
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
private val runMutexByKey = mutableMapOf<String, Mutex>()
|
||||||
|
|
||||||
private data class Pending(
|
private data class Pending(
|
||||||
val storageKey: String,
|
val storageKey: String,
|
||||||
@@ -24,46 +36,85 @@ object AttachmentDownloadScheduler {
|
|||||||
val enqueuedAt: Long,
|
val enqueuedAt: Long,
|
||||||
val work: suspend () -> String?,
|
val work: suspend () -> String?,
|
||||||
val result: CompletableDeferred<String?>,
|
val result: CompletableDeferred<String?>,
|
||||||
|
val keepAliveInBackground: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun isActive(storageKey: String): Boolean =
|
||||||
|
activeJobs[storageKey]?.isActive == true
|
||||||
|
|
||||||
private val waiting = mutableListOf<Pending>()
|
private val waiting = mutableListOf<Pending>()
|
||||||
private val keyToDeferred = mutableMapOf<String, CompletableDeferred<String?>>()
|
private val keyToDeferred = mutableMapOf<String, CompletableDeferred<String?>>()
|
||||||
|
private val activeJobs = mutableMapOf<String, Job>()
|
||||||
private var activeCount = 0
|
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(
|
suspend fun run(
|
||||||
storageKey: String,
|
storageKey: String,
|
||||||
messageId: Int,
|
messageId: Int,
|
||||||
work: suspend () -> String?,
|
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 {
|
val deferred = mutex.withLock {
|
||||||
keyToDeferred[storageKey] ?: run {
|
val created = CompletableDeferred<String?>()
|
||||||
val created = CompletableDeferred<String?>()
|
keyToDeferred[storageKey] = created
|
||||||
keyToDeferred[storageKey] = created
|
waiting.removeAll { it.storageKey == storageKey }
|
||||||
waiting.add(
|
waiting.add(
|
||||||
Pending(
|
Pending(
|
||||||
storageKey = storageKey,
|
storageKey = storageKey,
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
enqueuedAt = AttachmentMediaLog.nowMs(),
|
enqueuedAt = AttachmentMediaLog.nowMs(),
|
||||||
work = work,
|
work = work,
|
||||||
result = created,
|
result = created,
|
||||||
),
|
keepAliveInBackground = keepAliveInBackground,
|
||||||
)
|
),
|
||||||
sortWaitingLocked()
|
)
|
||||||
created
|
sortWaitingLocked()
|
||||||
}
|
created
|
||||||
}
|
}
|
||||||
pumpLocked()
|
pumpLocked()
|
||||||
return deferred.await()
|
deferred.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reprioritize() {
|
fun reprioritize() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock { sortWaitingLocked() }
|
||||||
sortWaitingLocked()
|
|
||||||
}
|
|
||||||
pumpLocked()
|
pumpLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,30 +123,75 @@ object AttachmentDownloadScheduler {
|
|||||||
val toStart = mutex.withLock {
|
val toStart = mutex.withLock {
|
||||||
val jobs = mutableListOf<Pending>()
|
val jobs = mutableListOf<Pending>()
|
||||||
while (activeCount < MAX_PARALLEL && waiting.isNotEmpty()) {
|
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++
|
activeCount++
|
||||||
jobs.add(next)
|
jobs.add(next)
|
||||||
}
|
}
|
||||||
jobs
|
jobs
|
||||||
}
|
}
|
||||||
for (pending in toStart) {
|
for (pending in toStart) {
|
||||||
scope.launch {
|
val job = scope.launch {
|
||||||
runPending(pending)
|
try {
|
||||||
|
runPending(pending)
|
||||||
|
} finally {
|
||||||
|
mutex.withLock {
|
||||||
|
activeJobs.remove(pending.storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutex.withLock {
|
||||||
|
activeJobs[pending.storageKey] = job
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runPending(pending: Pending) {
|
private suspend fun runPending(pending: Pending) {
|
||||||
val outcome = runCatching { pending.work() }
|
if (pending.keepAliveInBackground) {
|
||||||
mutex.withLock {
|
AttachmentDownloadForeground.onFileDownloadStarted(pending.storageKey)
|
||||||
activeCount = (activeCount - 1).coerceAtLeast(0)
|
}
|
||||||
keyToDeferred.remove(pending.storageKey)
|
try {
|
||||||
|
val outcome = pending.work()
|
||||||
|
mutex.withLock {
|
||||||
|
if (!pending.result.isCompleted) {
|
||||||
|
pending.result.complete(outcome)
|
||||||
|
}
|
||||||
|
if (keyToDeferred[pending.storageKey] === pending.result) {
|
||||||
|
keyToDeferred.remove(pending.storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
mutex.withLock {
|
||||||
|
if (!pending.result.isCompleted) {
|
||||||
|
pending.result.complete(null)
|
||||||
|
}
|
||||||
|
if (keyToDeferred[pending.storageKey] === pending.result) {
|
||||||
|
keyToDeferred.remove(pending.storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
mutex.withLock {
|
||||||
|
if (!pending.result.isCompleted) {
|
||||||
|
pending.result.completeExceptionally(error)
|
||||||
|
}
|
||||||
|
if (keyToDeferred[pending.storageKey] === pending.result) {
|
||||||
|
keyToDeferred.remove(pending.storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
if (pending.keepAliveInBackground) {
|
||||||
|
AttachmentDownloadForeground.onFileDownloadFinished(pending.storageKey)
|
||||||
|
}
|
||||||
|
mutex.withLock {
|
||||||
|
activeCount = (activeCount - 1).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
pumpLocked()
|
||||||
}
|
}
|
||||||
outcome.fold(
|
|
||||||
onSuccess = { pending.result.complete(it) },
|
|
||||||
onFailure = { pending.result.completeExceptionally(it) },
|
|
||||||
)
|
|
||||||
pumpLocked()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sortWaitingLocked() {
|
private fun sortWaitingLocked() {
|
||||||
@@ -106,3 +202,15 @@ object AttachmentDownloadScheduler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun checkAttachmentDownloadActive(storageKey: String) {
|
||||||
|
if (AttachmentDownloadNotifier.isCancelled(storageKey)) {
|
||||||
|
throw CancellationException("attachment download cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cooperative cancel check for in-flight decrypt/download loops. */
|
||||||
|
internal suspend fun ensureAttachmentDownloadActive(storageKey: String) {
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
checkAttachmentDownloadActive(storageKey)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ import androidx.compose.runtime.Composable
|
|||||||
expect fun rememberCreateDownloadDestinationLauncher(
|
expect fun rememberCreateDownloadDestinationLauncher(
|
||||||
onDestination: (String?) -> Unit,
|
onDestination: (String?) -> Unit,
|
||||||
): (filename: String, mimeType: 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"
|
"xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
"ppt" -> "application/vnd.ms-powerpoint"
|
"ppt" -> "application/vnd.ms-powerpoint"
|
||||||
"pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
"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"
|
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
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
@@ -48,6 +49,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -81,11 +83,16 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.AttachmentDownloadNotifier
|
import ru.fromchat.api.AttachmentDownloadNotifier
|
||||||
|
import ru.fromchat.ui.scaleOnPress
|
||||||
import ru.fromchat.api.DmEnvelope
|
import ru.fromchat.api.DmEnvelope
|
||||||
import ru.fromchat.api.DmFile
|
import ru.fromchat.api.DmFile
|
||||||
import ru.fromchat.attachment_image_load_failed
|
import ru.fromchat.attachment_image_load_failed
|
||||||
import ru.fromchat.attachment_retry
|
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_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 val IMAGE_SIZE = 160.dp
|
||||||
private const val BLUR_FADE_MS = 450
|
private const val BLUR_FADE_MS = 450
|
||||||
@@ -108,6 +115,8 @@ fun AttachmentPreview(
|
|||||||
awaitingServerAck: Boolean = false,
|
awaitingServerAck: Boolean = false,
|
||||||
/** 0–100 upload progress when isUploading; null = indefinite */
|
/** 0–100 upload progress when isUploading; null = indefinite */
|
||||||
uploadProgress: Int? = null,
|
uploadProgress: Int? = null,
|
||||||
|
uploadError: String? = null,
|
||||||
|
onRetryUpload: (() -> Unit)? = null,
|
||||||
fileThumbnail: String? = null,
|
fileThumbnail: String? = null,
|
||||||
fileAspectRatio: Float? = null,
|
fileAspectRatio: Float? = null,
|
||||||
fileSizeBytes: Long? = null,
|
fileSizeBytes: Long? = null,
|
||||||
@@ -120,7 +129,8 @@ fun AttachmentPreview(
|
|||||||
isAuthor: Boolean = false,
|
isAuthor: Boolean = false,
|
||||||
/** Message text shown in attachment download/upload logs. */
|
/** Message text shown in attachment download/upload logs. */
|
||||||
messageLabel: String? = null,
|
messageLabel: String? = null,
|
||||||
modifier: Modifier = Modifier
|
onCancelUpload: (() -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val isImage = when {
|
val isImage = when {
|
||||||
file != null -> isImageFilename(file.name)
|
file != null -> isImageFilename(file.name)
|
||||||
@@ -157,7 +167,10 @@ fun AttachmentPreview(
|
|||||||
isAuthor = isAuthor,
|
isAuthor = isAuthor,
|
||||||
isUploading = isPendingFile && (isUploading || awaitingServerAck),
|
isUploading = isPendingFile && (isUploading || awaitingServerAck),
|
||||||
uploadProgress = if (isPendingFile) uploadProgress else null,
|
uploadProgress = if (isPendingFile) uploadProgress else null,
|
||||||
|
uploadError = if (isPendingFile) uploadError else null,
|
||||||
|
onRetryUpload = if (isPendingFile) onRetryUpload else null,
|
||||||
messageLabel = messageLabel,
|
messageLabel = messageLabel,
|
||||||
|
onCancelUpload = onCancelUpload,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -235,7 +248,10 @@ fun AttachmentPreview(
|
|||||||
isUploading = isUploading,
|
isUploading = isUploading,
|
||||||
awaitingServerAck = awaitingServerAck,
|
awaitingServerAck = awaitingServerAck,
|
||||||
uploadProgress = uploadProgress,
|
uploadProgress = uploadProgress,
|
||||||
|
uploadError = uploadError,
|
||||||
|
onRetryUpload = onRetryUpload,
|
||||||
messageLabel = messageLabel,
|
messageLabel = messageLabel,
|
||||||
|
onCancelUpload = onCancelUpload,
|
||||||
onFullyLoaded = { if (it) isFullyLoaded = true },
|
onFullyLoaded = { if (it) isFullyLoaded = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -262,9 +278,13 @@ private fun ChatImageTileContent(
|
|||||||
isUploading: Boolean,
|
isUploading: Boolean,
|
||||||
awaitingServerAck: Boolean,
|
awaitingServerAck: Boolean,
|
||||||
uploadProgress: Int?,
|
uploadProgress: Int?,
|
||||||
|
uploadError: String? = null,
|
||||||
|
onRetryUpload: (() -> Unit)? = null,
|
||||||
messageLabel: String? = null,
|
messageLabel: String? = null,
|
||||||
|
onCancelUpload: (() -> Unit)? = null,
|
||||||
onFullyLoaded: (Boolean) -> Unit = {},
|
onFullyLoaded: (Boolean) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val clipShape = attachmentImageCornerShape(isAuthor)
|
val clipShape = attachmentImageCornerShape(isAuthor)
|
||||||
val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
|
val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f }
|
val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f }
|
||||||
@@ -315,6 +335,17 @@ private fun ChatImageTileContent(
|
|||||||
val decryptFailed = remember(downloadProgressByKey, messageId, fileIndex, cacheClientId) {
|
val decryptFailed = remember(downloadProgressByKey, messageId, fileIndex, cacheClientId) {
|
||||||
AttachmentDownloadNotifier.isFailed(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 isAwaitingNetworkFull by remember(decryptCacheKey) { mutableStateOf(false) }
|
||||||
var loadAttempt by remember(decryptCacheKey) { mutableIntStateOf(0) }
|
var loadAttempt by remember(decryptCacheKey) { mutableIntStateOf(0) }
|
||||||
LaunchedEffect(showOutboundBlurOverlay) {
|
LaunchedEffect(showOutboundBlurOverlay) {
|
||||||
@@ -565,8 +596,9 @@ private fun ChatImageTileContent(
|
|||||||
|
|
||||||
val isDownloadingFullImage = !isOutboundPending && fullBitmap == null &&
|
val isDownloadingFullImage = !isOutboundPending && fullBitmap == null &&
|
||||||
(downloadProgress != null || isAwaitingNetworkFull)
|
(downloadProgress != null || isAwaitingNetworkFull)
|
||||||
val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay
|
val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay && !downloadCancelled
|
||||||
val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !isOutboundPending
|
val showDownloadCancelledOverlay = downloadCancelled && fullBitmap == null && !isOutboundPending
|
||||||
|
val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !isOutboundPending && !downloadCancelled
|
||||||
val showSpinnerOnly = fullBitmap == null && thumbBitmap == null && !hasLocalSource &&
|
val showSpinnerOnly = fullBitmap == null && thumbBitmap == null && !hasLocalSource &&
|
||||||
!showLoadFailedOverlay &&
|
!showLoadFailedOverlay &&
|
||||||
!showOutboundBlurOverlay &&
|
!showOutboundBlurOverlay &&
|
||||||
@@ -634,19 +666,62 @@ private fun ChatImageTileContent(
|
|||||||
)
|
)
|
||||||
LaunchedEffect(full) { onFullyLoaded(true) }
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)),
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
ExpressiveUploadIndicator(
|
CancellableAttachmentProgressIndicator(
|
||||||
uploadProgress = downloadProgress,
|
progress = downloadProgress,
|
||||||
|
onCancel = {
|
||||||
|
scope.launch {
|
||||||
|
AttachmentDownloadNotifier.cancelDownload(
|
||||||
|
messageId = messageId,
|
||||||
|
fileIndex = fileIndex,
|
||||||
|
clientMessageId = cacheClientId,
|
||||||
|
)
|
||||||
|
AttachmentDownloadScheduler.cancel(decryptCacheKey)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showCloseScrim = true,
|
||||||
modifier = Modifier.size(48.dp),
|
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(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -658,7 +733,7 @@ private fun ChatImageTileContent(
|
|||||||
AttachmentImageLoadFailedOverlay(
|
AttachmentImageLoadFailedOverlay(
|
||||||
isAuthor = isAuthor,
|
isAuthor = isAuthor,
|
||||||
onRetry = {
|
onRetry = {
|
||||||
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
|
AttachmentDownloadNotifier.beginDownload(messageId, fileIndex, cacheClientId)
|
||||||
decryptFinished = false
|
decryptFinished = false
|
||||||
ApiClient.clearPartialEncryptedDownload(decryptCacheKey)
|
ApiClient.clearPartialEncryptedDownload(decryptCacheKey)
|
||||||
loadAttempt++
|
loadAttempt++
|
||||||
@@ -674,11 +749,60 @@ private fun ChatImageTileContent(
|
|||||||
uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null,
|
uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null,
|
||||||
clipShape = clipShape,
|
clipShape = clipShape,
|
||||||
contentScale = imageContentScale,
|
contentScale = imageContentScale,
|
||||||
|
onCancelUpload = if (isUploading && !awaitingServerAck) onCancelUpload else null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.matchParentSize()
|
.matchParentSize()
|
||||||
.alpha(outboundOverlayAlpha.value),
|
.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?,
|
uploadProgress: Int?,
|
||||||
clipShape: RoundedCornerShape,
|
clipShape: RoundedCornerShape,
|
||||||
contentScale: ContentScale = ContentScale.Fit,
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
|
onCancelUpload: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
@@ -711,17 +836,26 @@ private fun UploadingImageOverlay(
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
ExpressiveUploadIndicator(
|
if (onCancelUpload != null) {
|
||||||
uploadProgress = uploadProgress,
|
CancellableAttachmentProgressIndicator(
|
||||||
modifier = Modifier.size(56.dp)
|
progress = uploadProgress,
|
||||||
)
|
onCancel = onCancelUpload,
|
||||||
|
showCloseScrim = false,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ExpressiveUploadIndicator(
|
||||||
|
uploadProgress = uploadProgress,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExpressiveUploadIndicator(
|
internal fun ExpressiveUploadIndicator(
|
||||||
uploadProgress: Int?,
|
uploadProgress: Int?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
indicatorColor: Color? = null,
|
indicatorColor: Color? = null,
|
||||||
@@ -903,6 +1037,45 @@ internal fun decodeAttachmentThumbnailBase64(value: String): ByteArray? {
|
|||||||
return runCatching { Base64.decode(payload) }.getOrNull()
|
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
|
@Composable
|
||||||
private fun AttachmentImageLoadFailedOverlay(
|
private fun AttachmentImageLoadFailedOverlay(
|
||||||
isAuthor: Boolean,
|
isAuthor: Boolean,
|
||||||
@@ -973,11 +1146,13 @@ internal fun ExpressiveFileAttachmentRow(
|
|||||||
filename: String,
|
filename: String,
|
||||||
sizeBytes: Long?,
|
sizeBytes: Long?,
|
||||||
onClick: (() -> Unit)?,
|
onClick: (() -> Unit)?,
|
||||||
|
enableClick: Boolean = onClick != null,
|
||||||
isAuthor: Boolean,
|
isAuthor: Boolean,
|
||||||
isUploading: Boolean,
|
isUploading: Boolean,
|
||||||
uploadProgress: Int?,
|
uploadProgress: Int?,
|
||||||
isDownloaded: Boolean = false,
|
isDownloaded: Boolean = false,
|
||||||
modifier: Modifier = Modifier
|
onCancelProgress: (() -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
|
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
|
||||||
val supportingColor = if (isAuthor) {
|
val supportingColor = if (isAuthor) {
|
||||||
@@ -989,10 +1164,14 @@ internal fun ExpressiveFileAttachmentRow(
|
|||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.widthIn(max = 268.dp)
|
.widthIn(max = 268.dp)
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
.padding(horizontal = 6.dp, vertical = 8.dp)
|
||||||
.then(
|
.then(
|
||||||
if (onClick != null && !isUploading) {
|
if (onClick != null && enableClick) {
|
||||||
Modifier.clickable(onClick = onClick)
|
Modifier.scaleOnPress(
|
||||||
|
scale = 0.96f,
|
||||||
|
onClick = onClick,
|
||||||
|
indication = null,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
@@ -1000,53 +1179,14 @@ internal fun ExpressiveFileAttachmentRow(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
FileAttachmentLeadingSlot(
|
||||||
|
isProgressing = isUploading,
|
||||||
|
isDownloaded = isDownloaded,
|
||||||
|
uploadProgress = uploadProgress,
|
||||||
|
isAuthor = isAuthor,
|
||||||
|
onCancelProgress = onCancelProgress ?: {},
|
||||||
modifier = Modifier.size(leadingSize),
|
modifier = Modifier.size(leadingSize),
|
||||||
contentAlignment = Alignment.Center
|
)
|
||||||
) {
|
|
||||||
if (isUploading) {
|
|
||||||
ExpressiveUploadIndicator(
|
|
||||||
uploadProgress = uploadProgress,
|
|
||||||
modifier = Modifier.size(leadingSize),
|
|
||||||
indicatorColor = if (isAuthor) Color.White else null,
|
|
||||||
trackColorOverride = if (isAuthor) {
|
|
||||||
Color.White.copy(alpha = 0.28f)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Surface(
|
|
||||||
shape = CircleShape,
|
|
||||||
color = if (isAuthor) {
|
|
||||||
Color.White.copy(alpha = 0.22f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(leadingSize)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isDownloaded) {
|
|
||||||
Icons.Rounded.InsertDriveFile
|
|
||||||
} else {
|
|
||||||
Icons.Rounded.Download
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(26.dp),
|
|
||||||
tint = if (isAuthor) {
|
|
||||||
Color.White
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
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
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
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.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -29,10 +20,18 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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 androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
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.AttachmentDownloadNotifier
|
||||||
import ru.fromchat.api.DmEnvelope
|
import ru.fromchat.api.DmEnvelope
|
||||||
import ru.fromchat.api.DmFile
|
import ru.fromchat.api.DmFile
|
||||||
@@ -52,35 +51,36 @@ fun ChatFileAttachmentTile(
|
|||||||
isAuthor: Boolean,
|
isAuthor: Boolean,
|
||||||
isUploading: Boolean,
|
isUploading: Boolean,
|
||||||
uploadProgress: Int?,
|
uploadProgress: Int?,
|
||||||
|
uploadError: String? = null,
|
||||||
|
onRetryUpload: (() -> Unit)? = null,
|
||||||
messageLabel: String? = null,
|
messageLabel: String? = null,
|
||||||
|
onCancelUpload: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val openFailedMessage = stringResource(Res.string.attachment_open_failed)
|
||||||
val isPendingLocal = pendingFileUri != null && file == null
|
val isPendingLocal = pendingFileUri != null && file == null
|
||||||
|
val uploadFailed = isPendingLocal && !uploadError.isNullOrBlank()
|
||||||
val mimeType = remember(filename) { mimeTypeForFilename(filename) }
|
val mimeType = remember(filename) { mimeTypeForFilename(filename) }
|
||||||
|
|
||||||
var exportUri by remember(messageId, fileIndex, clientMessageId) {
|
var cacheUri by remember(messageId, fileIndex, clientMessageId) {
|
||||||
mutableStateOf<String?>(null)
|
mutableStateOf<String?>(null)
|
||||||
}
|
}
|
||||||
var uriAccessible by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(messageId, fileIndex, clientMessageId) {
|
|
||||||
val stored = DownloadedFileRegistry.getExportUri(messageId, fileIndex, clientMessageId)
|
|
||||||
exportUri = stored
|
|
||||||
uriAccessible = stored != null && isExportUriAccessible(stored)
|
|
||||||
if (stored != null && !uriAccessible) {
|
|
||||||
DownloadedFileRegistry.removeExportUri(messageId, fileIndex, clientMessageId)
|
|
||||||
exportUri = null
|
|
||||||
AttachmentDownloadNotifier.clearProgress(
|
|
||||||
messageId = messageId,
|
|
||||||
fileIndex = fileIndex,
|
|
||||||
clientMessageId = clientMessageId,
|
|
||||||
mirrorAsFileAttachment = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadProgressByKey by AttachmentDownloadNotifier.progressPercentByKey.collectAsState()
|
val downloadProgressByKey by AttachmentDownloadNotifier.progressPercentByKey.collectAsState()
|
||||||
|
val downloadCancelledKeys by AttachmentDownloadNotifier.cancelledKeys.collectAsState()
|
||||||
|
LaunchedEffect(messageId, fileIndex, clientMessageId) {
|
||||||
|
AttachmentDownloadNotifier.restorePausedForAttachment(
|
||||||
|
messageId = messageId,
|
||||||
|
fileIndex = fileIndex,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
mirrorAsFileAttachment = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(messageId, fileIndex, clientMessageId, pendingFileUri, downloadProgressByKey, downloadCancelledKeys) {
|
||||||
|
cacheUri = DecryptedFileCache.getCached(messageId, fileIndex, clientMessageId)
|
||||||
|
}
|
||||||
val downloadProgress = remember(downloadProgressByKey, messageId, fileIndex, clientMessageId) {
|
val downloadProgress = remember(downloadProgressByKey, messageId, fileIndex, clientMessageId) {
|
||||||
DownloadedFileRegistry.resolveDownloadPercent(
|
DownloadedFileRegistry.resolveDownloadPercent(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
@@ -89,123 +89,179 @@ fun ChatFileAttachmentTile(
|
|||||||
progressByKey = downloadProgressByKey,
|
progressByKey = downloadProgressByKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val isDownloading = !isUploading &&
|
val downloadPaused = remember(downloadCancelledKeys, messageId, fileIndex, clientMessageId) {
|
||||||
downloadProgress != null &&
|
AttachmentDownloadNotifier.isCancelled(
|
||||||
downloadProgress < 100
|
messageId = messageId,
|
||||||
|
fileIndex = fileIndex,
|
||||||
var pendingDownload by remember { mutableStateOf<PendingFileDownload?>(null) }
|
clientMessageId = clientMessageId,
|
||||||
|
mirrorAsFileAttachment = true,
|
||||||
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 isDownloading = !isUploading && !uploadFailed &&
|
||||||
|
downloadProgress != null &&
|
||||||
|
downloadProgress < 100 &&
|
||||||
|
!downloadPaused
|
||||||
|
|
||||||
val isDownloaded = !isPendingLocal && uriAccessible && exportUri != null && !isDownloading
|
val showPausedProgress = downloadPaused && downloadProgress != null && downloadProgress < 100
|
||||||
val showWavy = isUploading || isDownloading
|
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 {
|
val onRowClick: (() -> Unit)? = when {
|
||||||
isUploading -> null
|
openableLocalUri != null -> {
|
||||||
isPendingLocal && pendingFileUri != null -> {
|
|
||||||
{
|
{
|
||||||
scope.launch {
|
scope.launch {
|
||||||
withContext(Dispatchers.Default) {
|
val opened = openCachedAttachmentFile(openableLocalUri, mimeType, filename)
|
||||||
openExportUri(pendingFileUri, mimeType)
|
if (!opened) {
|
||||||
|
showAttachmentOpenFailed(openFailedMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isDownloaded && exportUri != null -> {
|
downloadPaused && file != null && dmEnvelope != null -> {
|
||||||
{
|
{
|
||||||
|
AttachmentDownloadNotifier.beginDownload(
|
||||||
|
messageId = messageId,
|
||||||
|
fileIndex = fileIndex,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
mirrorAsFileAttachment = true,
|
||||||
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val accessible = isExportUriAccessible(exportUri!!)
|
val ok = DmFileDownloader.downloadToCache(
|
||||||
if (!accessible) {
|
messageId = messageId,
|
||||||
DownloadedFileRegistry.removeExportUri(messageId, fileIndex, clientMessageId)
|
fileIndex = fileIndex,
|
||||||
exportUri = null
|
file = file,
|
||||||
uriAccessible = false
|
envelope = dmEnvelope,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
messageLabel = messageLabel,
|
||||||
|
)
|
||||||
|
if (ok) {
|
||||||
|
cacheUri = DecryptedFileCache.getCached(messageId, fileIndex, clientMessageId)
|
||||||
AttachmentDownloadNotifier.clearProgress(
|
AttachmentDownloadNotifier.clearProgress(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
fileIndex = fileIndex,
|
fileIndex = fileIndex,
|
||||||
clientMessageId = clientMessageId,
|
clientMessageId = clientMessageId,
|
||||||
mirrorAsFileAttachment = true,
|
mirrorAsFileAttachment = true,
|
||||||
)
|
)
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
if (!openExportUri(exportUri!!, mimeType)) {
|
|
||||||
DownloadedFileRegistry.removeExportUri(messageId, fileIndex, clientMessageId)
|
|
||||||
exportUri = null
|
|
||||||
uriAccessible = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
file != null && dmEnvelope != null && !isDownloading -> {
|
file != null && dmEnvelope != null && !isDownloading && !downloadPaused -> {
|
||||||
{
|
{
|
||||||
pendingDownload = PendingFileDownload(
|
AttachmentDownloadNotifier.beginDownload(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
fileIndex = fileIndex,
|
fileIndex = fileIndex,
|
||||||
file = file,
|
|
||||||
envelope = dmEnvelope,
|
|
||||||
currentUserId = currentUserId,
|
|
||||||
clientMessageId = clientMessageId,
|
clientMessageId = clientMessageId,
|
||||||
messageLabel = messageLabel,
|
mirrorAsFileAttachment = true,
|
||||||
)
|
)
|
||||||
launchDestinationPicker(filename, mimeType)
|
scope.launch {
|
||||||
|
val ok = DmFileDownloader.downloadToCache(
|
||||||
|
messageId = messageId,
|
||||||
|
fileIndex = fileIndex,
|
||||||
|
file = file,
|
||||||
|
envelope = dmEnvelope,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
messageLabel = messageLabel,
|
||||||
|
)
|
||||||
|
if (ok) {
|
||||||
|
cacheUri = DecryptedFileCache.getCached(messageId, fileIndex, clientMessageId)
|
||||||
|
AttachmentDownloadNotifier.clearProgress(
|
||||||
|
messageId = messageId,
|
||||||
|
fileIndex = fileIndex,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
mirrorAsFileAttachment = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
ExpressiveFileAttachmentRow(
|
val enableRowClick = onRowClick != null &&
|
||||||
filename = filename,
|
(openableLocalUri != null || !showWavy || downloadPaused)
|
||||||
sizeBytes = sizeBytes,
|
|
||||||
onClick = onRowClick,
|
|
||||||
isAuthor = isAuthor,
|
|
||||||
isUploading = showWavy,
|
|
||||||
uploadProgress = when {
|
|
||||||
isUploading -> uploadProgress
|
|
||||||
isDownloading -> downloadProgress
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
isDownloaded = isDownloaded,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class PendingFileDownload(
|
val onCancelProgress: (() -> Unit)? = when {
|
||||||
val messageId: Int,
|
isUploading && onCancelUpload != null -> onCancelUpload
|
||||||
val fileIndex: Int,
|
isDownloading || showPausedProgress -> {
|
||||||
val file: DmFile,
|
{
|
||||||
val envelope: DmEnvelope,
|
val storageKey = DownloadedFileRegistry.storageKey(
|
||||||
val currentUserId: Int?,
|
messageId = messageId,
|
||||||
val clientMessageId: String?,
|
fileIndex = fileIndex,
|
||||||
val messageLabel: String?,
|
clientMessageId = clientMessageId,
|
||||||
)
|
)
|
||||||
|
scope.launch {
|
||||||
|
AttachmentDownloadNotifier.cancelDownload(
|
||||||
|
messageId = messageId,
|
||||||
|
fileIndex = fileIndex,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
mirrorAsFileAttachment = true,
|
||||||
|
)
|
||||||
|
AttachmentDownloadScheduler.cancel(storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val failedLabel = when (uploadError) {
|
||||||
|
UPLOAD_ERROR_FILE_TOO_LARGE -> stringResource(Res.string.attachment_upload_failed_too_large)
|
||||||
|
null, "" -> null
|
||||||
|
else -> stringResource(Res.string.attachment_upload_failed)
|
||||||
|
}
|
||||||
|
val retryText = stringResource(Res.string.attachment_retry)
|
||||||
|
val retryCd = stringResource(Res.string.cd_attachment_upload_retry)
|
||||||
|
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
|
||||||
|
|
||||||
|
Box(modifier = modifier.widthIn(max = 280.dp)) {
|
||||||
|
ExpressiveFileAttachmentRow(
|
||||||
|
filename = filename,
|
||||||
|
sizeBytes = sizeBytes,
|
||||||
|
onClick = onRowClick,
|
||||||
|
enableClick = enableRowClick,
|
||||||
|
isAuthor = isAuthor,
|
||||||
|
isUploading = showProgressing,
|
||||||
|
uploadProgress = when {
|
||||||
|
isUploading && showProgressing -> displayUploadProgress
|
||||||
|
isDownloading || showPausedProgress -> downloadProgress ?: 0
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
isDownloaded = showAsDownloadedIcon,
|
||||||
|
onCancelProgress = onCancelProgress,
|
||||||
|
)
|
||||||
|
if (uploadFailed && failedLabel != null && onRetryUpload != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = failedLabel,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = headlineColor,
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = onRetryUpload,
|
||||||
|
modifier = Modifier.semantics { contentDescription = retryCd },
|
||||||
|
) {
|
||||||
|
Text(text = retryText, color = headlineColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ abstract class ChatPanel(
|
|||||||
if (cid.isEmpty()) return
|
if (cid.isEmpty()) return
|
||||||
if (message.pendingFileUri != null) {
|
if (message.pendingFileUri != null) {
|
||||||
clearOutboundImageCaches(cid, message.id)
|
clearOutboundImageCaches(cid, message.id)
|
||||||
|
clearOutboundFileCaches(cid, message.id)
|
||||||
}
|
}
|
||||||
removeMessage(message.id)
|
removeMessage(message.id)
|
||||||
ru.fromchat.api.outbox.OutgoingMessageCoordinator.cancelOutboundMessage(cid, outboxConversationId())
|
ru.fromchat.api.outbox.OutgoingMessageCoordinator.cancelOutboundMessage(cid, outboxConversationId())
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val saveMessageImage = rememberSaveMessageImage { /* best-effort */ }
|
val saveMessageImage = rememberSaveMessageImage { /* best-effort */ }
|
||||||
|
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val profileUserId = panelState.profileUserId
|
val profileUserId = panelState.profileUserId
|
||||||
@@ -154,6 +155,13 @@ fun ChatScreen(
|
|||||||
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
|
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 {
|
val subtitleKey = when {
|
||||||
!online -> "connecting"
|
!online -> "connecting"
|
||||||
connectionStatus == ConnectionStatus.UPDATING -> "updating"
|
connectionStatus == ConnectionStatus.UPDATING -> "updating"
|
||||||
@@ -416,17 +424,22 @@ fun ChatScreen(
|
|||||||
if (panel.getRecipientId() != null) {
|
if (panel.getRecipientId() != null) {
|
||||||
AttachmentUploadNotifier.progressFlow.collect { progress ->
|
AttachmentUploadNotifier.progressFlow.collect { progress ->
|
||||||
when (progress) {
|
when (progress) {
|
||||||
|
is AttachmentUploadProgress.Pending ->
|
||||||
|
panel.updateMessageByClientMessageId(progress.jobId) {
|
||||||
|
it.copy(uploadProgress = 0, uploadError = null)
|
||||||
|
}
|
||||||
is AttachmentUploadProgress.InProgress ->
|
is AttachmentUploadProgress.InProgress ->
|
||||||
panel.updateMessageByClientMessageId(progress.jobId) {
|
panel.updateMessageByClientMessageId(progress.jobId) {
|
||||||
it.copy(uploadProgress = progress.percent)
|
it.copy(uploadProgress = progress.percent, uploadError = null)
|
||||||
}
|
}
|
||||||
is AttachmentUploadProgress.Success ->
|
is AttachmentUploadProgress.Success ->
|
||||||
panel.updateMessageByClientMessageId(progress.jobId) {
|
panel.updateMessageByClientMessageId(progress.jobId) {
|
||||||
it.copy(uploadProgress = null)
|
it.copy(uploadProgress = null)
|
||||||
}
|
}
|
||||||
is AttachmentUploadProgress.Failed -> {
|
is AttachmentUploadProgress.Failed -> {
|
||||||
if (progress.error != "Cancelled") {
|
if (progress.error == "Cancelled") return@collect
|
||||||
scope.launch { panel.cancelQueuedMessageByClientId(progress.jobId) }
|
panel.updateMessageByClientMessageId(progress.jobId) {
|
||||||
|
it.copy(uploadProgress = null, uploadError = progress.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
@@ -505,13 +518,18 @@ fun ChatScreen(
|
|||||||
prepareOutboundFileForSend(
|
prepareOutboundFileForSend(
|
||||||
clientMessageId = jobId,
|
clientMessageId = jobId,
|
||||||
sourceUri = att.uri,
|
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(
|
val optimisticMessage = Message(
|
||||||
id = tempId,
|
id = tempId,
|
||||||
user_id = currentUserId ?: -1,
|
user_id = currentUserId ?: -1,
|
||||||
content = plaintext.ifBlank { att.filename },
|
content = plaintext,
|
||||||
timestamp = nowMessageTimestampIso(),
|
timestamp = nowMessageTimestampIso(),
|
||||||
is_read = false,
|
is_read = false,
|
||||||
is_edited = false,
|
is_edited = false,
|
||||||
@@ -526,27 +544,31 @@ fun ChatScreen(
|
|||||||
pendingFilename = att.filename,
|
pendingFilename = att.filename,
|
||||||
uploadJobId = jobId,
|
uploadJobId = jobId,
|
||||||
uploadProgress = 0,
|
uploadProgress = 0,
|
||||||
pendingFileAspectRatio = staged?.aspectRatio ?: aspectRatio,
|
pendingFileAspectRatio = staged.aspectRatio ?: aspectRatio,
|
||||||
fileDimensions = imageDimensions?.let { listOf(it) },
|
fileDimensions = imageDimensions?.let { listOf(it) },
|
||||||
|
fileSizes = staged.sizeBytes.takeIf { it > 0L }?.let { listOf(it) },
|
||||||
)
|
)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
panel.addMessage(optimisticMessage)
|
panel.addMessage(optimisticMessage)
|
||||||
}
|
}
|
||||||
if (staged == null) {
|
AttachmentUploadNotifier.emit(
|
||||||
withContext(Dispatchers.Main) {
|
AttachmentUploadProgress.InProgress(
|
||||||
panel.cancelQueuedMessageByClientId(jobId)
|
jobId = jobId,
|
||||||
}
|
percent = 1,
|
||||||
return@launch
|
filename = att.filename,
|
||||||
}
|
),
|
||||||
|
messageLabel = plaintext,
|
||||||
|
)
|
||||||
OutgoingMessageCoordinator.enqueueDmAttachment(
|
OutgoingMessageCoordinator.enqueueDmAttachment(
|
||||||
recipientId = recipientId,
|
recipientId = recipientId,
|
||||||
plaintext = plaintext.ifBlank { att.filename },
|
plaintext = plaintext,
|
||||||
clientMessageId = jobId,
|
clientMessageId = jobId,
|
||||||
replyToId = replyToId,
|
replyToId = replyToId,
|
||||||
fileUri = fileUri,
|
fileUri = fileUri,
|
||||||
filename = att.filename,
|
filename = att.filename,
|
||||||
optimisticMessage = optimisticMessage,
|
optimisticMessage = optimisticMessage,
|
||||||
aspectRatio = staged?.aspectRatio ?: aspectRatio,
|
aspectRatio = staged.aspectRatio ?: aspectRatio,
|
||||||
|
fileSizeBytes = staged.sizeBytes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -658,6 +680,7 @@ fun ChatScreen(
|
|||||||
message.user_id > 0
|
message.user_id > 0
|
||||||
) {
|
) {
|
||||||
{
|
{
|
||||||
|
profileSharedSourceMessageId = message.id
|
||||||
ProfileCache.mergePreviewFromPublicMessage(message)
|
ProfileCache.mergePreviewFromPublicMessage(message)
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
"profile/${message.user_id}" +
|
"profile/${message.user_id}" +
|
||||||
@@ -677,17 +700,31 @@ fun ChatScreen(
|
|||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
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 =
|
sharedAvatarNavKey =
|
||||||
if (
|
if (
|
||||||
panel.supportsNavigateToSenderProfile &&
|
panel.supportsNavigateToSenderProfile &&
|
||||||
sharedTransitionScope != null &&
|
sharedTransitionScope != null &&
|
||||||
animatedVisibilityScope != null &&
|
animatedVisibilityScope != null &&
|
||||||
message.user_id != currentUserId &&
|
message.user_id != currentUserId &&
|
||||||
message.user_id > 0
|
message.user_id > 0 &&
|
||||||
|
profileSharedSourceMessageId == message.id
|
||||||
) {
|
) {
|
||||||
publicChatProfileSharedAvatarKey(
|
publicChatProfileSharedAvatarKey(
|
||||||
message.user_id,
|
message.user_id,
|
||||||
message.id
|
message.id,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -772,6 +809,11 @@ fun ChatScreen(
|
|||||||
onSave = { message ->
|
onSave = { message ->
|
||||||
resolveSavableMessageImage(message)?.let { savable ->
|
resolveSavableMessageImage(message)?.let { savable ->
|
||||||
saveMessageImage(savable)
|
saveMessageImage(savable)
|
||||||
|
} ?: resolveSavableMessageFile(message)?.let { savable ->
|
||||||
|
saveMessageFile(savable)
|
||||||
|
scope.launch {
|
||||||
|
ensureFileDownloadedForSave(message, savable)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCancelSend = { message ->
|
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
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -11,7 +11,7 @@ import ru.fromchat.api.DmEnvelope
|
|||||||
import ru.fromchat.api.DmFile
|
import ru.fromchat.api.DmFile
|
||||||
import ru.fromchat.api.AttachmentDownloadNotifier
|
import ru.fromchat.api.AttachmentDownloadNotifier
|
||||||
import ru.fromchat.api.AttachmentDownloadProgress
|
import ru.fromchat.api.AttachmentDownloadProgress
|
||||||
import ru.fromchat.crypto.decryptFile
|
import ru.fromchat.crypto.decryptFileToPath
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disk + in-memory cache for decrypted DM images.
|
* Disk + in-memory cache for decrypted DM images.
|
||||||
@@ -115,7 +115,7 @@ object DecryptedImageCache {
|
|||||||
ru.fromchat.core.cache.readOutboundFileBytes(sourceUri)
|
ru.fromchat.core.cache.readOutboundFileBytes(sourceUri)
|
||||||
}.getOrNull() ?: return
|
}.getOrNull() ?: return
|
||||||
if (bytes.isEmpty()) return
|
if (bytes.isEmpty()) return
|
||||||
withContext(Dispatchers.Default + NonCancellable) {
|
withContext(Dispatchers.Default) {
|
||||||
cacheMutex.withLock {
|
cacheMutex.withLock {
|
||||||
if (readDisk(idKey) == null) {
|
if (readDisk(idKey) == null) {
|
||||||
writeCacheLocked(idKey, bytes)
|
writeCacheLocked(idKey, bytes)
|
||||||
@@ -167,47 +167,58 @@ object DecryptedImageCache {
|
|||||||
cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
|
cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
|
||||||
|
|
||||||
val label = AttachmentMediaLog.messageLabel(messageLabel)
|
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 {
|
runCatching {
|
||||||
AttachmentDownloadScheduler.run(storageKey = key, messageId = messageId) {
|
AttachmentDownloadScheduler.run(
|
||||||
|
storageKey = key,
|
||||||
|
messageId = messageId,
|
||||||
|
work = {
|
||||||
|
AttachmentMediaLog.download(
|
||||||
|
"decrypt_start",
|
||||||
|
"key" to key,
|
||||||
|
"file" to file.path,
|
||||||
|
"msgId" to messageId,
|
||||||
|
"visible" to AttachmentDownloadVisibility.isPrioritized(messageId),
|
||||||
|
"msg" to label,
|
||||||
|
)
|
||||||
|
decryptAndPersist(
|
||||||
|
key = key,
|
||||||
|
messageId = messageId,
|
||||||
|
fileIndex = fileIndex,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
file = file,
|
||||||
|
envelope = envelope,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
messageLabel = label,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
if (error !is CancellationException) {
|
||||||
|
ApiClient.clearPartialEncryptedDownload(key)
|
||||||
AttachmentMediaLog.download(
|
AttachmentMediaLog.download(
|
||||||
"decrypt_start",
|
"decrypt_exception",
|
||||||
"key" to key,
|
"key" to key,
|
||||||
"file" to file.path,
|
|
||||||
"msgId" to messageId,
|
"msgId" to messageId,
|
||||||
"visible" to AttachmentDownloadVisibility.isPrioritized(messageId),
|
|
||||||
"msg" to label,
|
"msg" to label,
|
||||||
|
"err" to (error.message ?: error::class.simpleName),
|
||||||
)
|
)
|
||||||
decryptAndPersist(
|
AttachmentDownloadNotifier.emit(
|
||||||
key = key,
|
AttachmentDownloadProgress.Failed(
|
||||||
|
storageKey = key,
|
||||||
|
error = error.message ?: "decrypt_failed",
|
||||||
|
),
|
||||||
|
messageLabel = label,
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
fileIndex = fileIndex,
|
fileIndex = fileIndex,
|
||||||
clientMessageId = clientMessageId,
|
clientMessageId = clientMessageId,
|
||||||
file = file,
|
|
||||||
envelope = envelope,
|
|
||||||
currentUserId = currentUserId,
|
|
||||||
messageLabel = label,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
|
||||||
ApiClient.clearPartialEncryptedDownload(key)
|
|
||||||
AttachmentMediaLog.download(
|
|
||||||
"decrypt_exception",
|
|
||||||
"key" to key,
|
|
||||||
"msgId" to messageId,
|
|
||||||
"msg" to label,
|
|
||||||
"err" to (error.message ?: error::class.simpleName),
|
|
||||||
)
|
|
||||||
AttachmentDownloadNotifier.emit(
|
|
||||||
AttachmentDownloadProgress.Failed(
|
|
||||||
storageKey = key,
|
|
||||||
error = error.message ?: "decrypt_failed",
|
|
||||||
),
|
|
||||||
messageLabel = label,
|
|
||||||
messageId = messageId,
|
|
||||||
fileIndex = fileIndex,
|
|
||||||
clientMessageId = clientMessageId,
|
|
||||||
)
|
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
if (uri != null && messageId > 0) {
|
if (uri != null && messageId > 0) {
|
||||||
@@ -263,7 +274,7 @@ object DecryptedImageCache {
|
|||||||
fileIndex: Int,
|
fileIndex: Int,
|
||||||
localFileUri: String,
|
localFileUri: String,
|
||||||
clientMessageId: String? = null,
|
clientMessageId: String? = null,
|
||||||
): String? = withContext(Dispatchers.Default + NonCancellable) {
|
): String? = withContext(Dispatchers.Default) {
|
||||||
val key = storageKey(messageId, fileIndex, clientMessageId)
|
val key = storageKey(messageId, fileIndex, clientMessageId)
|
||||||
cacheMutex.withLock { resolveUriLocked(key) }?.let { existing ->
|
cacheMutex.withLock { resolveUriLocked(key) }?.let { existing ->
|
||||||
AttachmentMediaLog.diskCache(
|
AttachmentMediaLog.diskCache(
|
||||||
@@ -304,6 +315,7 @@ object DecryptedImageCache {
|
|||||||
currentUserId: Int?,
|
currentUserId: Int?,
|
||||||
messageLabel: String? = null,
|
messageLabel: String? = null,
|
||||||
): String? {
|
): String? {
|
||||||
|
ensureAttachmentDownloadActive(key)
|
||||||
cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
|
cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
|
||||||
AttachmentDownloadNotifier.emit(
|
AttachmentDownloadNotifier.emit(
|
||||||
AttachmentDownloadProgress.InProgress(key, 1),
|
AttachmentDownloadProgress.InProgress(key, 1),
|
||||||
@@ -313,13 +325,27 @@ object DecryptedImageCache {
|
|||||||
clientMessageId = clientMessageId,
|
clientMessageId = clientMessageId,
|
||||||
)
|
)
|
||||||
val t0 = AttachmentMediaLog.nowMs()
|
val t0 = AttachmentMediaLog.nowMs()
|
||||||
val bytes = runCatching {
|
val outputPath = diskPath(key)
|
||||||
decryptFile(
|
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,
|
file = file,
|
||||||
envelope = envelope,
|
envelope = envelope,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
|
outputPath = outputPath,
|
||||||
downloadResumeKey = key,
|
downloadResumeKey = key,
|
||||||
onDownloadProgress = { percent ->
|
onDownloadProgress = { percent ->
|
||||||
|
checkAttachmentDownloadActive(key)
|
||||||
AttachmentDownloadNotifier.emit(
|
AttachmentDownloadNotifier.emit(
|
||||||
AttachmentDownloadProgress.InProgress(key, percent.coerceIn(0, 100)),
|
AttachmentDownloadProgress.InProgress(key, percent.coerceIn(0, 100)),
|
||||||
messageLabel = messageLabel,
|
messageLabel = messageLabel,
|
||||||
@@ -330,6 +356,9 @@ object DecryptedImageCache {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
|
if (error is CancellationException) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
ApiClient.clearPartialEncryptedDownload(key)
|
ApiClient.clearPartialEncryptedDownload(key)
|
||||||
AttachmentMediaLog.download(
|
AttachmentMediaLog.download(
|
||||||
"decrypt_failed",
|
"decrypt_failed",
|
||||||
@@ -347,10 +376,11 @@ object DecryptedImageCache {
|
|||||||
clientMessageId = clientMessageId,
|
clientMessageId = clientMessageId,
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
if (bytes == null) {
|
if (decryptedSize == null) {
|
||||||
ApiClient.clearPartialEncryptedDownload(key)
|
ApiClient.clearPartialEncryptedDownload(key)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
ensureAttachmentDownloadActive(key)
|
||||||
AttachmentDownloadNotifier.emit(
|
AttachmentDownloadNotifier.emit(
|
||||||
AttachmentDownloadProgress.InProgress(key, 99),
|
AttachmentDownloadProgress.InProgress(key, 99),
|
||||||
messageLabel = messageLabel,
|
messageLabel = messageLabel,
|
||||||
@@ -358,8 +388,9 @@ object DecryptedImageCache {
|
|||||||
fileIndex = fileIndex,
|
fileIndex = fileIndex,
|
||||||
clientMessageId = clientMessageId,
|
clientMessageId = clientMessageId,
|
||||||
)
|
)
|
||||||
|
ApiClient.clearPartialEncryptedDownload(key)
|
||||||
val uri = cacheMutex.withLock {
|
val uri = cacheMutex.withLock {
|
||||||
resolveUriLocked(key) ?: writeCacheLocked(key, bytes)
|
resolveUriLocked(key) ?: commitCachePathLocked(key, outputPath)
|
||||||
}
|
}
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
ApiClient.clearPartialEncryptedDownload(key)
|
ApiClient.clearPartialEncryptedDownload(key)
|
||||||
@@ -367,7 +398,7 @@ object DecryptedImageCache {
|
|||||||
"decrypt_persist_failed",
|
"decrypt_persist_failed",
|
||||||
"key" to key,
|
"key" to key,
|
||||||
"msg" to messageLabel,
|
"msg" to messageLabel,
|
||||||
"bytes" to bytes.size,
|
"bytes" to decryptedSize,
|
||||||
)
|
)
|
||||||
AttachmentDownloadNotifier.emit(
|
AttachmentDownloadNotifier.emit(
|
||||||
AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
|
AttachmentDownloadProgress.Failed(key, "cache_write_failed"),
|
||||||
@@ -381,7 +412,7 @@ object DecryptedImageCache {
|
|||||||
AttachmentMediaLog.download(
|
AttachmentMediaLog.download(
|
||||||
"decrypt_persist_ok",
|
"decrypt_persist_ok",
|
||||||
"key" to key,
|
"key" to key,
|
||||||
"bytes" to bytes.size,
|
"bytes" to decryptedSize,
|
||||||
"ms" to (AttachmentMediaLog.nowMs() - t0),
|
"ms" to (AttachmentMediaLog.nowMs() - t0),
|
||||||
"uri" to uri,
|
"uri" to uri,
|
||||||
"msg" to messageLabel,
|
"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) {
|
private fun invalidatePath(path: String) {
|
||||||
runCatching { PlatformFileSystem.delete(path) }
|
runCatching { PlatformFileSystem.delete(path) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +1,34 @@
|
|||||||
package ru.fromchat.ui.chat
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.withContext
|
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.DmEnvelope
|
||||||
import ru.fromchat.api.DmFile
|
import ru.fromchat.api.DmFile
|
||||||
import ru.fromchat.crypto.decryptFile
|
|
||||||
|
|
||||||
object DmFileDownloader {
|
object DmFileDownloader {
|
||||||
suspend fun downloadToExportUri(
|
suspend fun downloadToCache(
|
||||||
messageId: Int,
|
messageId: Int,
|
||||||
fileIndex: Int,
|
fileIndex: Int,
|
||||||
file: DmFile,
|
file: DmFile,
|
||||||
envelope: DmEnvelope,
|
envelope: DmEnvelope,
|
||||||
currentUserId: Int?,
|
currentUserId: Int?,
|
||||||
clientMessageId: String?,
|
clientMessageId: String?,
|
||||||
exportUri: String,
|
|
||||||
messageLabel: String? = null,
|
messageLabel: String? = null,
|
||||||
): Boolean {
|
): Boolean = withContext(Dispatchers.Default) {
|
||||||
val key = DownloadedFileRegistry.storageKey(messageId, fileIndex, clientMessageId)
|
DecryptedFileCache.getOrDecrypt(
|
||||||
val label = AttachmentMediaLog.messageLabel(messageLabel)
|
messageId = messageId,
|
||||||
return withContext(Dispatchers.Default + NonCancellable) {
|
fileIndex = fileIndex,
|
||||||
runCatching {
|
file = file,
|
||||||
val written = AttachmentDownloadScheduler.run(storageKey = key, messageId = messageId) {
|
envelope = envelope,
|
||||||
AttachmentDownloadNotifier.emit(
|
currentUserId = currentUserId,
|
||||||
AttachmentDownloadProgress.InProgress(key, 1),
|
clientMessageId = clientMessageId,
|
||||||
messageLabel = label,
|
messageLabel = messageLabel,
|
||||||
messageId = messageId,
|
) != null
|
||||||
fileIndex = fileIndex,
|
|
||||||
clientMessageId = clientMessageId,
|
|
||||||
mirrorAsFileAttachment = true,
|
|
||||||
)
|
|
||||||
val bytes = decryptFile(
|
|
||||||
file = file,
|
|
||||||
envelope = envelope,
|
|
||||||
currentUserId = currentUserId,
|
|
||||||
downloadResumeKey = key,
|
|
||||||
onDownloadProgress = { percent ->
|
|
||||||
AttachmentDownloadNotifier.emit(
|
|
||||||
AttachmentDownloadProgress.InProgress(key, percent.coerceIn(0, 100)),
|
|
||||||
messageLabel = label,
|
|
||||||
messageId = messageId,
|
|
||||||
fileIndex = fileIndex,
|
|
||||||
clientMessageId = clientMessageId,
|
|
||||||
mirrorAsFileAttachment = true,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
AttachmentDownloadNotifier.emit(
|
|
||||||
AttachmentDownloadProgress.InProgress(key, 99),
|
|
||||||
messageLabel = label,
|
|
||||||
messageId = messageId,
|
|
||||||
fileIndex = fileIndex,
|
|
||||||
clientMessageId = clientMessageId,
|
|
||||||
mirrorAsFileAttachment = true,
|
|
||||||
)
|
|
||||||
if (!writeBytesToExportUri(exportUri, bytes)) {
|
|
||||||
ApiClient.clearPartialEncryptedDownload(key)
|
|
||||||
AttachmentDownloadNotifier.emit(
|
|
||||||
AttachmentDownloadProgress.Failed(key, "write_failed"),
|
|
||||||
messageLabel = label,
|
|
||||||
messageId = messageId,
|
|
||||||
fileIndex = fileIndex,
|
|
||||||
clientMessageId = clientMessageId,
|
|
||||||
mirrorAsFileAttachment = true,
|
|
||||||
)
|
|
||||||
return@run null
|
|
||||||
}
|
|
||||||
DownloadedFileRegistry.setExportUri(
|
|
||||||
messageId = messageId,
|
|
||||||
fileIndex = fileIndex,
|
|
||||||
clientMessageId = clientMessageId,
|
|
||||||
exportUri = exportUri,
|
|
||||||
)
|
|
||||||
AttachmentDownloadNotifier.emit(
|
|
||||||
AttachmentDownloadProgress.Success(storageKey = key, messageId = messageId),
|
|
||||||
messageLabel = label,
|
|
||||||
messageId = messageId,
|
|
||||||
fileIndex = fileIndex,
|
|
||||||
clientMessageId = clientMessageId,
|
|
||||||
mirrorAsFileAttachment = true,
|
|
||||||
)
|
|
||||||
exportUri
|
|
||||||
}
|
|
||||||
written != null
|
|
||||||
}.onFailure { error ->
|
|
||||||
ApiClient.clearPartialEncryptedDownload(key)
|
|
||||||
AttachmentDownloadNotifier.emit(
|
|
||||||
AttachmentDownloadProgress.Failed(
|
|
||||||
storageKey = key,
|
|
||||||
error = error.message ?: "download_failed",
|
|
||||||
),
|
|
||||||
messageLabel = label,
|
|
||||||
messageId = messageId,
|
|
||||||
fileIndex = fileIndex,
|
|
||||||
clientMessageId = clientMessageId,
|
|
||||||
mirrorAsFileAttachment = true,
|
|
||||||
)
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect suspend fun writeBytesToExportUri(exportUri: String, bytes: ByteArray): Boolean
|
expect suspend fun openCachedAttachmentFile(
|
||||||
|
cacheUri: String,
|
||||||
expect suspend fun isExportUriAccessible(exportUri: String): Boolean
|
mimeType: String,
|
||||||
|
displayFilename: String? = null,
|
||||||
expect fun openExportUri(exportUri: String, mimeType: String): Boolean
|
): Boolean
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ object DownloadedFileRegistry {
|
|||||||
private val memory = mutableMapOf<String, String>()
|
private val memory = mutableMapOf<String, String>()
|
||||||
private var diskIndexLoaded = false
|
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(
|
fun storageKey(
|
||||||
messageId: Int,
|
messageId: Int,
|
||||||
fileIndex: 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 labelCancelSend = stringResource(Res.string.action_cancel_send)
|
||||||
val isQueued = message.isQueuedOutbound() && isAuthor
|
val isQueued = message.isQueuedOutbound() && isAuthor
|
||||||
val savableImage = resolveSavableMessageImage(message)
|
val savableImage = resolveSavableMessageImage(message)
|
||||||
|
val savableFile = resolveSavableMessageFile(message)
|
||||||
|
val canSave = savableImage != null || savableFile != null
|
||||||
|
|
||||||
Box(modifier = containerModifier) {
|
Box(modifier = containerModifier) {
|
||||||
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape))
|
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape))
|
||||||
@@ -342,7 +344,7 @@ private fun ContextMenuContent(
|
|||||||
onClick = { onCopy(message) }
|
onClick = { onCopy(message) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (savableImage != null) {
|
if (canSave) {
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
icon = Icons.Rounded.SaveAlt,
|
icon = Icons.Rounded.SaveAlt,
|
||||||
text = labelSave,
|
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 org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.api.isQueuedOutbound
|
||||||
import ru.fromchat.api.formatMessageTimeLocal
|
import ru.fromchat.api.formatMessageTimeLocal
|
||||||
import ru.fromchat.*
|
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 {
|
private fun isMessageCorrupted(message: Message): Boolean {
|
||||||
val files = message.files ?: return false
|
val files = message.files ?: return false
|
||||||
return files.withIndex().any { (index, file) ->
|
return files.withIndex().any { (index, file) ->
|
||||||
@@ -85,7 +99,9 @@ fun MessageItem(
|
|||||||
isContextMenuForThisMessage: Boolean = false,
|
isContextMenuForThisMessage: Boolean = false,
|
||||||
sharedTransitionScope: SharedTransitionScope? = null,
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
animatedVisibilityScope: AnimatedVisibilityScope? = 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.
|
// Cache derived values per message to avoid recomputing in every recomposition.
|
||||||
val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
|
val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
|
||||||
@@ -205,6 +221,21 @@ fun MessageItem(
|
|||||||
val pendingHasOutboundFile = message.pendingFileUri != null &&
|
val pendingHasOutboundFile = message.pendingFileUri != null &&
|
||||||
message.files.isNullOrEmpty() &&
|
message.files.isNullOrEmpty() &&
|
||||||
!pendingIsImage
|
!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 = (
|
val firstContentIsImage = (
|
||||||
!showUsername || isAuthor
|
!showUsername || isAuthor
|
||||||
) && message.reply_to == null && (
|
) && message.reply_to == null && (
|
||||||
@@ -428,6 +459,7 @@ fun MessageItem(
|
|||||||
val awaitingServer = message.id < 0 && message.files.isNullOrEmpty()
|
val awaitingServer = message.id < 0 && message.files.isNullOrEmpty()
|
||||||
val isOutboundPendingImage = awaitingServer && pendingIsImage
|
val isOutboundPendingImage = awaitingServer && pendingIsImage
|
||||||
val awaitingServerAck = isOutboundPendingImage &&
|
val awaitingServerAck = isOutboundPendingImage &&
|
||||||
|
!uploadFailed &&
|
||||||
message.uploadProgress == null
|
message.uploadProgress == null
|
||||||
AttachmentPreview(
|
AttachmentPreview(
|
||||||
file = primaryFile,
|
file = primaryFile,
|
||||||
@@ -435,9 +467,11 @@ fun MessageItem(
|
|||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
pendingFileUri = message.pendingFileUri,
|
pendingFileUri = message.pendingFileUri,
|
||||||
pendingFilename = message.pendingFilename,
|
pendingFilename = message.pendingFilename,
|
||||||
isUploading = isOutboundPendingImage,
|
isUploading = isOutboundPendingImage && !uploadFailed,
|
||||||
awaitingServerAck = awaitingServerAck,
|
awaitingServerAck = awaitingServerAck,
|
||||||
uploadProgress = message.uploadProgress,
|
uploadProgress = message.uploadProgress,
|
||||||
|
uploadError = message.uploadError,
|
||||||
|
onRetryUpload = onRetryUpload,
|
||||||
fileThumbnail = message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() },
|
fileThumbnail = message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() },
|
||||||
fileAspectRatio = imageAspectRatioForMessage(
|
fileAspectRatio = imageAspectRatioForMessage(
|
||||||
fileAspectRatios = message.fileAspectRatios,
|
fileAspectRatios = message.fileAspectRatios,
|
||||||
@@ -464,6 +498,7 @@ fun MessageItem(
|
|||||||
!isImageClosing,
|
!isImageClosing,
|
||||||
isAuthor = isAuthor,
|
isAuthor = isAuthor,
|
||||||
messageLabel = message.content,
|
messageLabel = message.content,
|
||||||
|
onCancelUpload = onCancelUpload,
|
||||||
modifier = if (firstContentIsImage) {
|
modifier = if (firstContentIsImage) {
|
||||||
Modifier.padding(all = 2.dp)
|
Modifier.padding(all = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
@@ -475,6 +510,7 @@ fun MessageItem(
|
|||||||
val awaitingServer = message.id < 0 && message.files.isNullOrEmpty()
|
val awaitingServer = message.id < 0 && message.files.isNullOrEmpty()
|
||||||
val isOutboundPendingFile = awaitingServer && pendingHasOutboundFile
|
val isOutboundPendingFile = awaitingServer && pendingHasOutboundFile
|
||||||
val awaitingServerAck = isOutboundPendingFile &&
|
val awaitingServerAck = isOutboundPendingFile &&
|
||||||
|
!uploadFailed &&
|
||||||
message.uploadProgress == null
|
message.uploadProgress == null
|
||||||
AttachmentPreview(
|
AttachmentPreview(
|
||||||
file = primaryFile,
|
file = primaryFile,
|
||||||
@@ -482,16 +518,19 @@ fun MessageItem(
|
|||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
pendingFileUri = message.pendingFileUri,
|
pendingFileUri = message.pendingFileUri,
|
||||||
pendingFilename = message.pendingFilename,
|
pendingFilename = message.pendingFilename,
|
||||||
isUploading = isOutboundPendingFile,
|
isUploading = isOutboundPendingFile && !uploadFailed,
|
||||||
awaitingServerAck = awaitingServerAck,
|
awaitingServerAck = awaitingServerAck,
|
||||||
uploadProgress = message.uploadProgress,
|
uploadProgress = message.uploadProgress,
|
||||||
|
uploadError = message.uploadError,
|
||||||
|
onRetryUpload = onRetryUpload,
|
||||||
fileSizeBytes = message.fileSizes?.firstOrNull(),
|
fileSizeBytes = message.fileSizes?.firstOrNull(),
|
||||||
messageId = message.id,
|
messageId = message.id,
|
||||||
fileIndex = 0,
|
fileIndex = 0,
|
||||||
clientMessageId = message.client_message_id,
|
clientMessageId = message.client_message_id,
|
||||||
isAuthor = isAuthor,
|
isAuthor = isAuthor,
|
||||||
messageLabel = message.content,
|
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 ->
|
message.files?.forEachIndexed { index, file ->
|
||||||
@@ -532,22 +571,23 @@ fun MessageItem(
|
|||||||
isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing,
|
isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing,
|
||||||
isAuthor = isAuthor,
|
isAuthor = isAuthor,
|
||||||
messageLabel = message.content,
|
messageLabel = message.content,
|
||||||
|
onCancelUpload = onCancelUpload,
|
||||||
modifier = if (isFirstImage && firstContentIsImage && isImage) {
|
modifier = if (isFirstImage && firstContentIsImage && isImage) {
|
||||||
Modifier.padding(all = 2.dp)
|
Modifier.padding(all = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
Modifier.padding(
|
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
|
vertical = if (isImage) 2.dp else 4.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val hideFilenamePlaceholderCaption = message.pendingFileUri != null &&
|
if (
|
||||||
message.files.isNullOrEmpty() &&
|
message.content.isNotBlank() &&
|
||||||
message.pendingFilename != null &&
|
!isCorrupted &&
|
||||||
message.content == message.pendingFilename
|
!isFilenameOnlyMessageCaption(message)
|
||||||
if (message.content.isNotBlank() && !isCorrupted && !hideFilenamePlaceholderCaption) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
|
|||||||
panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio
|
panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio
|
||||||
},
|
},
|
||||||
uploadProgress = if (confirmed) null else panel.uploadProgress ?: db.uploadProgress,
|
uploadProgress = if (confirmed) null else panel.uploadProgress ?: db.uploadProgress,
|
||||||
|
uploadError = if (confirmed) null else panel.uploadError ?: db.uploadError,
|
||||||
files = db.files ?: panel.files,
|
files = db.files ?: panel.files,
|
||||||
dmEnvelope = db.dmEnvelope ?: panel.dmEnvelope,
|
dmEnvelope = db.dmEnvelope ?: panel.dmEnvelope,
|
||||||
fileThumbnails = db.fileThumbnails ?: panel.fileThumbnails,
|
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
|
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(
|
suspend fun prepareOutboundFileForSend(
|
||||||
clientMessageId: String,
|
clientMessageId: String,
|
||||||
sourceUri: String,
|
sourceUri: String,
|
||||||
|
optimisticMessageId: Int,
|
||||||
|
displayFilename: String,
|
||||||
): StagedOutboundPreview? = withContext(Dispatchers.Default) {
|
): StagedOutboundPreview? = withContext(Dispatchers.Default) {
|
||||||
val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: return@withContext null
|
val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: return@withContext null
|
||||||
val staged = runCatching {
|
val staged = runCatching {
|
||||||
stageOutboundFileForUpload(instanceId, clientMessageId, sourceUri)
|
stageOutboundFileForUpload(instanceId, clientMessageId, sourceUri)
|
||||||
}.getOrNull() ?: return@withContext null
|
}.getOrNull() ?: return@withContext null
|
||||||
if (staged.sizeBytes <= 0L) 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(
|
data class StagedOutboundPreview(
|
||||||
val stagedUri: String,
|
val stagedUri: String,
|
||||||
val aspectRatio: Float?,
|
val aspectRatio: Float?,
|
||||||
|
val sizeBytes: Long = 0L,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +38,7 @@ suspend fun prepareOutboundImageForSend(
|
|||||||
val decodeTarget = previewSeedDecodeSize(aspectRatio)
|
val decodeTarget = previewSeedDecodeSize(aspectRatio)
|
||||||
LocalDecodedImageCache.loadFull(storageKey, staged.uri, decodeTarget)
|
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). */
|
/** 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,
|
scrollToMessageId = scrollToMessageId,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedContentScope,
|
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.crypto.decryptEnvelope
|
||||||
import ru.fromchat.ui.chat.AvatarInfo
|
import ru.fromchat.ui.chat.AvatarInfo
|
||||||
import ru.fromchat.ui.chat.ChatPanel
|
import ru.fromchat.ui.chat.ChatPanel
|
||||||
|
import ru.fromchat.ui.chat.DecryptedFileCache
|
||||||
import ru.fromchat.ui.chat.DecryptedImageCache
|
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.DownloadedFileRegistry
|
||||||
import ru.fromchat.ui.chat.DmTypingHandler
|
import ru.fromchat.ui.chat.DmTypingHandler
|
||||||
import ru.fromchat.ui.chat.TypingHandler
|
import ru.fromchat.ui.chat.TypingHandler
|
||||||
@@ -346,6 +349,19 @@ class DmPanel(
|
|||||||
fileIndex = 0,
|
fileIndex = 0,
|
||||||
clientMessageId = cid,
|
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(
|
val localPreviewUri = resolveLocalPreviewUri(
|
||||||
confirmed.copy(
|
confirmed.copy(
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ FROM message
|
|||||||
WHERE instanceId = ? AND conversationId = ? AND id = ? AND deletedFlag = 0
|
WHERE instanceId = ? AND conversationId = ? AND id = ? AND deletedFlag = 0
|
||||||
LIMIT 1;
|
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:
|
deleteMessagesForConversation:
|
||||||
DELETE FROM message
|
DELETE FROM message
|
||||||
WHERE instanceId = ? AND conversationId = ?;
|
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.NSCachesDirectory
|
||||||
import platform.Foundation.NSData
|
import platform.Foundation.NSData
|
||||||
import platform.Foundation.NSFileManager
|
import platform.Foundation.NSFileManager
|
||||||
|
import platform.Foundation.NSNumber
|
||||||
import platform.Foundation.NSURL
|
import platform.Foundation.NSURL
|
||||||
|
import platform.Foundation.NSURLFileSizeKey
|
||||||
import platform.Foundation.NSUserDomainMask
|
import platform.Foundation.NSUserDomainMask
|
||||||
import platform.Foundation.create
|
import platform.Foundation.create
|
||||||
import platform.Foundation.dataWithContentsOfURL
|
import platform.Foundation.dataWithContentsOfURL
|
||||||
import platform.Foundation.writeToFile
|
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun uploadDir(instanceId: String): String {
|
private fun uploadDir(instanceId: String): String {
|
||||||
@@ -33,23 +39,218 @@ private fun uploadDir(instanceId: String): String {
|
|||||||
return path
|
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun blobPath(instanceId: String, clientMessageId: String): String {
|
private fun blobPath(instanceId: String, clientMessageId: String): String =
|
||||||
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
"${uploadDir(instanceId)}/${safeId(clientMessageId)}.enc"
|
||||||
return "${uploadDir(instanceId)}/$safeId.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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun cipherPath(instanceId: String, clientMessageId: String): String {
|
private fun atomicReplace(partPath: String, finalPath: String) {
|
||||||
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
if (!NSFileManager.defaultManager.fileExistsAtPath(partPath)) {
|
||||||
return "${uploadDir(instanceId)}/$safeId.cipher.json"
|
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 {
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
actual suspend fun queryOutboundUriSizeBytes(fileUri: String): Long? = withContext(Dispatchers.Default) {
|
||||||
return "${uploadDir(instanceId)}/$safeId.source"
|
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)
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
private fun readBytesAtPath(path: String): ByteArray? {
|
private fun readBytesAtPath(path: String): ByteArray? {
|
||||||
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return null
|
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return null
|
||||||
@@ -61,35 +262,6 @@ private fun readBytesAtPath(path: String): ByteArray? {
|
|||||||
return ByteArray(length) { i -> bytePtr[i] }
|
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
|
actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
@@ -103,22 +275,85 @@ actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@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) {
|
actual suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray) {
|
||||||
withContext(Dispatchers.Default) {
|
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
actual suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? =
|
actual suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? =
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
|
if (!isEncryptedBlobReady(instanceId, clientMessageId, null)) return@withContext null
|
||||||
readBytesAtPath(blobPath(instanceId, clientMessageId))
|
readBytesAtPath(blobPath(instanceId, clientMessageId))
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
actual suspend fun encryptedUploadBlobSizeBytes(instanceId: String, clientMessageId: String): Long? =
|
||||||
actual suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String) {
|
|
||||||
withContext(Dispatchers.Default) {
|
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
actual suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String) {
|
actual suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String) {
|
||||||
withContext(Dispatchers.Default) {
|
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(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) {
|
actual suspend fun clearUploadSecretsOnly(instanceId: String, clientMessageId: String) {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
NSFileManager.defaultManager.removeItemAtPath(blobPath(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(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 {
|
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(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes for GCM" }
|
||||||
require(key.size == AES_KEY_SIZE) { "Key must be 32 bytes" }
|
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 }
|
return remember(onDestination) { launcher }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual suspend fun persistExportUriPermissionIfNeeded(exportUri: String) {
|
||||||
|
// iOS export URIs are file URLs; no persistable permission grant.
|
||||||
|
}
|
||||||
|
|
||||||
private fun defaultDownloadsDirectoryUrl(): NSURL? {
|
private fun defaultDownloadsDirectoryUrl(): NSURL? {
|
||||||
val manager = NSFileManager.defaultManager
|
val manager = NSFileManager.defaultManager
|
||||||
return manager.URLForDirectory(
|
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
|
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.NSURL
|
||||||
import platform.Foundation.create
|
|
||||||
import platform.Foundation.writeToFile
|
|
||||||
import platform.Foundation.writeToURL
|
|
||||||
import platform.UIKit.UIApplication
|
import platform.UIKit.UIApplication
|
||||||
import ru.fromchat.platform.iosTopViewController
|
import ru.fromchat.platform.iosTopViewController
|
||||||
|
|
||||||
actual suspend fun writeBytesToExportUri(exportUri: String, bytes: ByteArray): Boolean =
|
actual suspend fun openCachedAttachmentFile(
|
||||||
withContext(Dispatchers.Default) {
|
cacheUri: String,
|
||||||
val url = NSURL.URLWithString(exportUri) ?: NSURL.fileURLWithPath(exportUri.removePrefix("file://"))
|
mimeType: String,
|
||||||
val nsData = bytes.usePinned { pinned ->
|
displayFilename: String?,
|
||||||
NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
|
): Boolean {
|
||||||
} ?: return@withContext false
|
val url = NSURL.URLWithString(cacheUri) ?: NSURL.fileURLWithPath(cacheUri.removePrefix("file://"))
|
||||||
nsData.writeToURL(url, true) || run {
|
|
||||||
val path = url.path ?: return@withContext false
|
|
||||||
nsData.writeToFile(path, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun isExportUriAccessible(exportUri: String): Boolean = withContext(Dispatchers.Default) {
|
|
||||||
val url = NSURL.URLWithString(exportUri) ?: NSURL.fileURLWithPath(exportUri.removePrefix("file://"))
|
|
||||||
val path = url.path
|
|
||||||
if (path != null) {
|
|
||||||
return@withContext NSFileManager.defaultManager.fileExistsAtPath(path)
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
NSFileManager.defaultManager.isReadableFileAtPath(url.absoluteString ?: return@runCatching false)
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun openExportUri(exportUri: String, mimeType: String): Boolean {
|
|
||||||
val url = NSURL.URLWithString(exportUri) ?: NSURL.fileURLWithPath(exportUri.removePrefix("file://"))
|
|
||||||
val host = iosTopViewController() ?: return false
|
|
||||||
return UIApplication.sharedApplication.openURL(url)
|
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