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