From 1a93145ee5d9a6c50ac6c42ff8d7cdadb5143a03 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 19 May 2026 15:45:21 +0300 Subject: [PATCH] Make a robust sending system --- app/shared/build.gradle.kts | 5 + .../src/androidMain/AndroidManifest.xml | 8 +- .../ru/fromchat/api/ApiClient.android.kt | 11 +- .../api/AttachmentUploadQueue.android.kt | 296 +------ .../api/db/MessageDatabaseDriver.android.kt | 32 +- .../api/db/MessageDatabaseLock.android.kt | 5 + .../api/outbox/MediaUploadForegroundHelper.kt | 72 ++ .../api/outbox/OutboxProcessor.android.kt | 39 + .../fromchat/api/outbox/OutboxSendWorker.kt | 63 ++ .../core/cache/FromChatCacheDirs.android.kt | 12 + .../cache/FromChatCacheGeneration.android.kt | 30 + .../cache/ResumableUploadStorage.android.kt | 108 +++ .../ru/fromchat/crypto/dm/DmCrypto.android.kt | 20 +- .../ui/chat/AttachmentPickers.android.kt | 97 ++- .../ui/chat/LocalDecodedImageCache.android.kt | 144 ++++ .../composeResources/values-ru/strings.xml | 19 + .../composeResources/values/strings.xml | 20 + .../kotlin/ru/fromchat/api/ApiClient.kt | 199 ++++- .../api/AttachmentDownloadNotifier.kt | 117 +++ .../fromchat/api/AttachmentUploadNotifier.kt | 52 ++ .../ru/fromchat/api/AttachmentUploadQueue.kt | 2 +- .../kotlin/ru/fromchat/api/ClientMessageId.kt | 14 + .../kotlin/ru/fromchat/api/MessageOrdering.kt | 18 + .../ru/fromchat/api/MessageTimestamps.kt | 55 ++ .../kotlin/ru/fromchat/api/Models.kt | 13 + .../ru/fromchat/api/OptimisticMessageIds.kt | 10 + .../kotlin/ru/fromchat/api/ProfileCache.kt | 114 +-- .../ru/fromchat/api/WebSocketManager.kt | 7 + .../ru/fromchat/api/db/ConversationIds.kt | 18 + .../fromchat/api/db/DmStoredMessageContent.kt | 173 ++++ .../fromchat/api/db/InstanceRegistryStore.kt | 111 +++ .../ru/fromchat/api/db/LocalCacheWipe.kt | 26 + .../ru/fromchat/api/db/MessageCacheStore.kt | 654 +++++++++----- .../api/db/MessageDatabaseExpectedSchema.kt | 180 ++++ .../ru/fromchat/api/db/MessageDatabaseLock.kt | 3 + .../api/db/MessageDatabaseProvider.kt | 60 +- .../api/db/MessageDatabaseSchemaGuard.kt | 486 +++++++++++ .../ru/fromchat/api/db/MessageRepository.kt | 69 ++ .../ru/fromchat/api/db/ProfileCacheStore.kt | 61 ++ .../api/outbox/DmAttachmentOutboxHandler.kt | 400 +++++++++ .../ru/fromchat/api/outbox/OutboxPayloads.kt | 35 + .../ru/fromchat/api/outbox/OutboxProcessor.kt | 8 + .../api/outbox/OutgoingMessageCoordinator.kt | 307 +++++++ .../ru/fromchat/core/ServerConfigKeys.kt | 6 + .../kotlin/ru/fromchat/core/Settings.kt | 87 +- .../ru/fromchat/core/cache/CacheContext.kt | 47 + .../ru/fromchat/core/cache/CacheValidator.kt | 56 ++ .../fromchat/core/cache/FromChatCacheDirs.kt | 4 + .../core/cache/FromChatCacheGeneration.kt | 8 + .../cache/OutboundFileUnavailableException.kt | 16 + .../core/cache/ResumableUploadStorage.kt | 31 + .../kotlin/ru/fromchat/core/config/Config.kt | 6 +- .../fromchat/core/instance/InstanceIdGuard.kt | 43 + .../core/instance/InstanceIdResolve.kt | 112 +++ .../core/instance/ServerConfigProbe.kt | 101 +++ .../core/instance/SessionInstanceBootstrap.kt | 62 ++ .../kotlin/ru/fromchat/crypto/DmCrypto.kt | 21 +- .../kotlin/ru/fromchat/crypto/dm/DmCrypto.kt | 3 + .../commonMain/kotlin/ru/fromchat/ui/App.kt | 29 + .../ui/chat/AttachmentDownloadScheduler.kt | 108 +++ .../ui/chat/AttachmentDownloadVisibility.kt | 40 + .../ui/chat/AttachmentImageGeometry.kt | 63 ++ .../ru/fromchat/ui/chat/AttachmentMediaLog.kt | 39 + .../ru/fromchat/ui/chat/AttachmentPickers.kt | 3 + .../ru/fromchat/ui/chat/AttachmentPreview.kt | 810 ++++++++++++------ .../kotlin/ru/fromchat/ui/chat/ChatPanel.kt | 91 +- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 220 +++-- .../fromchat/ui/chat/DecryptedImageCache.kt | 441 +++++++++- .../ui/chat/ImageFullscreenPreview.kt | 235 +++-- .../ui/chat/LocalDecodedImageCache.kt | 172 ++++ .../ru/fromchat/ui/chat/MessageContextMenu.kt | 91 +- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 149 ++-- .../ru/fromchat/ui/chat/MessageListDedup.kt | 80 ++ .../ru/fromchat/ui/chat/MessageUiMerge.kt | 71 ++ .../fromchat/ui/chat/OutboundImageStaging.kt | 60 ++ .../ru/fromchat/ui/chat/PublicChatPanel.kt | 18 +- .../fromchat/ui/chat/PublicChatPanelCache.kt | 17 +- .../ru/fromchat/ui/chat/PublicChatScreen.kt | 13 + .../kotlin/ru/fromchat/ui/dm/DmNav.kt | 12 +- .../kotlin/ru/fromchat/ui/dm/DmPanel.kt | 307 ++++--- .../kotlin/ru/fromchat/ui/dm/DmPanelCache.kt | 40 +- .../kotlin/ru/fromchat/ui/dm/DmScreen.kt | 32 +- .../kotlin/ru/fromchat/ui/main/ChatsTab.kt | 23 +- .../ui/main/settings/SettingsScreens.kt | 61 ++ .../fromchat/ui/setup/ServerConfigScreen.kt | 181 ++-- .../ru/fromchat/db/MessageDatabase.sq | 182 +++- .../kotlin/ru/fromchat/api/ApiClient.ios.kt | 5 +- .../fromchat/api/AttachmentUploadQueue.ios.kt | 38 +- .../api/db/MessageDatabaseDriver.ios.kt | 16 +- .../api/db/MessageDatabaseLock.ios.kt | 14 + .../api/outbox/OutboxProcessor.ios.kt | 14 + .../core/cache/FromChatCacheDirs.ios.kt | 23 + .../core/cache/FromChatCacheGeneration.ios.kt | 61 ++ .../core/cache/ResumableUploadStorage.ios.kt | 146 ++++ .../ru/fromchat/crypto/dm/DmCrypto.ios.kt | 25 +- .../fromchat/ui/chat/AttachmentPickers.ios.kt | 2 + .../ui/chat/LocalDecodedImageCache.ios.kt | 94 ++ gradle/libs.versions.toml | 2 + .../utils/ClipboardSupport.android.kt | 27 + .../utils/files/PlatformFileSystem.android.kt | 4 +- .../kotlin/com/pr0gramm3r101/utils/Utils.kt | 15 +- .../utils/ClipboardSupport.ios.kt | 22 + .../utils/files/PlatformFileSystem.ios.kt | 4 + 103 files changed, 7434 insertions(+), 1442 deletions(-) create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/MediaUploadForegroundHelper.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxSendWorker.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.android.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadNotifier.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/ClientMessageId.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageOrdering.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageTimestamps.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/OptimisticMessageIds.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ConversationIds.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/db/InstanceRegistryStore.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/db/LocalCacheWipe.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseExpectedSchema.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseSchemaGuard.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageRepository.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ProfileCacheStore.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/DmAttachmentOutboxHandler.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxPayloads.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutgoingMessageCoordinator.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/core/ServerConfigKeys.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheContext.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheValidator.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/OutboundFileUnavailableException.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdGuard.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdResolve.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/ServerConfigProbe.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/SessionInstanceBootstrap.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadScheduler.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadVisibility.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentImageGeometry.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMediaLog.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageListDedup.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundImageStaging.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.ios.kt create mode 100644 utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/ClipboardSupport.android.kt create mode 100644 utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/ClipboardSupport.ios.kt diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index c330782..8644bbf 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -83,6 +83,7 @@ kotlin { } androidMain.dependencies { + implementation(libs.androidx.exifinterface) implementation(libs.ktor.client.okhttp) implementation(libs.firebase.messaging) implementation(libs.androidx.activity.compose) @@ -117,6 +118,10 @@ compose.resources { generateResClass = auto } +tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach { + dependsOn("generateResourceAccessorsForCommonMain") +} + tasks.register("generateResourceAccessors") { dependsOn( *( diff --git a/app/shared/src/androidMain/AndroidManifest.xml b/app/shared/src/androidMain/AndroidManifest.xml index 8de968b..8b60dae 100644 --- a/app/shared/src/androidMain/AndroidManifest.xml +++ b/app/shared/src/androidMain/AndroidManifest.xml @@ -1,14 +1,20 @@ - + + + diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt index 98974af..87a1437 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/ApiClient.android.kt @@ -3,5 +3,14 @@ package ru.fromchat.api import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout -actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp, block) \ No newline at end of file +actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit) = + HttpClient(OkHttp) { + install(HttpTimeout) { + requestTimeoutMillis = 30_000 + connectTimeoutMillis = 5_000 + socketTimeoutMillis = 30_000 + } + block() + } \ No newline at end of file diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt index 14580c6..35ef5f5 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt @@ -1,294 +1,28 @@ package ru.fromchat.api -import android.content.Context -import android.net.Uri -import java.io.File -import org.json.JSONObject -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.Data -import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import com.pr0gramm3r101.utils.UtilsLibrary -import com.pr0gramm3r101.utils.crypto.Base64 -import com.pr0gramm3r101.utils.settings.settings -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.runBlocking -import ru.fromchat.crypto.transport.TransportCiphertext -import ru.fromchat.crypto.transport.TransportCrypto - -private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024 -private const val DEFAULT_CHUNK_SIZE = 262_144 - -private fun dmBlobCacheFile(context: Context, jobId: String): File = - File(context.cacheDir, "dm_resumable_$jobId.blob") - -private fun dmTransportCipherPrefsKey(jobId: String): String = "dm_upload_transport_cipher_$jobId" - -private object AttachmentUploadEvents { - val flow = MutableSharedFlow(extraBufferCapacity = 64) -} +import kotlinx.coroutines.launch +import ru.fromchat.api.outbox.OutgoingMessageCoordinator +import ru.fromchat.api.outbox.scheduleOutboxProcessing +import ru.fromchat.core.cache.CacheContext actual object AttachmentUploadQueue { - actual val progressFlow: SharedFlow = AttachmentUploadEvents.flow + actual val progressFlow: SharedFlow = AttachmentUploadNotifier.progressFlow actual fun enqueue(job: AttachmentUploadJob) { - AttachmentUploadEvents.flow.tryEmit( - AttachmentUploadProgress.Pending( - jobId = job.jobId, - filename = job.filename - ) - ) - - val request = OneTimeWorkRequestBuilder() - .setInputData( - Data.Builder() - .putString(DmAttachmentUploadWorker.KEY_JOB_ID, job.jobId) - .putString(DmAttachmentUploadWorker.KEY_FILE_URI, job.fileUri) - .putString(DmAttachmentUploadWorker.KEY_FILENAME, job.filename) - .putInt(DmAttachmentUploadWorker.KEY_RECIPIENT_ID, job.recipientId) - .putString(DmAttachmentUploadWorker.KEY_PLAINTEXT, job.plaintext) - .build() - ) - .setConstraints( - Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - ) - .setBackoffCriteria( - BackoffPolicy.EXPONENTIAL, - 10, - TimeUnit.SECONDS - ) - .build() - - WorkManager.getInstance(UtilsLibrary.context).enqueueUniqueWork( - uniqueWorkName(job.jobId), - ExistingWorkPolicy.REPLACE, - request - ) + scheduleOutboxProcessing(CacheContext.activeInstanceId.value.trim()) } actual fun cancel(jobId: String) { - val ctx = UtilsLibrary.context - dmBlobCacheFile(ctx, jobId).delete() - runBlocking { - settings.putString(dmTransportCipherPrefsKey(jobId), "") + val instanceId = CacheContext.activeInstanceId.value.trim() + if (instanceId.isEmpty()) return + MainScope().launch { + val row = ru.fromchat.api.db.MessageDatabaseProvider.database.messageDatabaseQueries + .selectOutboxItem(instanceId, jobId.trim()) + .executeAsOneOrNull() + val conversationId = row?.conversationId ?: return@launch + OutgoingMessageCoordinator.cancelOutboundMessage(jobId, conversationId) } - WorkManager.getInstance(ctx).cancelUniqueWork(uniqueWorkName(jobId)) - } - - private fun uniqueWorkName(jobId: String): String = "dm-attachment-upload-$jobId" -} - -class DmAttachmentUploadWorker( - appContext: Context, - params: WorkerParameters -) : CoroutineWorker(appContext, params) { - companion object { - const val KEY_JOB_ID = "job_id" - const val KEY_FILE_URI = "file_uri" - const val KEY_FILENAME = "filename" - const val KEY_RECIPIENT_ID = "recipient_id" - const val KEY_PLAINTEXT = "plaintext" - } - - override suspend fun doWork(): Result { - val jobId = inputData.getString(KEY_JOB_ID) - ?: return Result.failure() - val fileUri = inputData.getString(KEY_FILE_URI) - ?: return Result.failure() - val filename = inputData.getString(KEY_FILENAME) - ?: "file" - val recipientId = inputData.getInt(KEY_RECIPIENT_ID, -1) - val plaintext = inputData.getString(KEY_PLAINTEXT)?.trim().orEmpty() - - if (recipientId <= 0) return Result.failure() - if (plaintext.isBlank()) return Result.failure() - - return runCatching { - emitProgress(jobId, 0) - - val prepared = loadPreparedTransportAndBlob(applicationContext, jobId) - val encryptedBlob: ByteArray - val msgCipher: TransportCiphertext - - if (prepared != null) { - encryptedBlob = prepared.first - msgCipher = prepared.second - } else { - val staleUploadId = settings.getString(uploadIdKey(jobId), "").ifBlank { null } - if (staleUploadId != null) { - runCatching { ApiClient.abortDmUpload(staleUploadId) } - settings.putString(uploadIdKey(jobId), "") - } - - val transportKey = ApiClient.getTransportPublicKey() - val (freshCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret( - plaintext = plaintext, - transportPublicKeyB64 = transportKey.publicKeyB64 - ) - try { - val bytes = applicationContext.contentResolver.openInputStream(Uri.parse(fileUri))?.use { it.readBytes() } - ?: error("Failed to read file from URI") - val blob = TransportCrypto.encryptFileForTransport( - fileBytes = bytes, - transportPublicKeyB64 = transportKey.publicKeyB64, - ephemeralSecretKey = ephemeralSecret - ) - encryptedBlob = blob - msgCipher = freshCipher - savePreparedTransportAndBlob(applicationContext, jobId, encryptedBlob, msgCipher) - } finally { - ephemeralSecret.fill(0) - } - } - - if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) { - sendInline(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher) - } else { - sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher) - } - - clearPreparedTransportAndBlob(applicationContext, jobId) - clearResumableState(jobId) - AttachmentUploadEvents.flow.tryEmit(AttachmentUploadProgress.Success(jobId)) - Result.success() - }.getOrElse { error -> - AttachmentUploadEvents.flow.tryEmit( - AttachmentUploadProgress.Failed( - jobId = jobId, - error = error.message ?: "Upload failed" - ) - ) - if (runAttemptCount >= 5) Result.failure() else Result.retry() - } - } - - private suspend fun sendInline( - jobId: String, - recipientId: Int, - plaintext: String, - filename: String, - encryptedBlob: ByteArray, - msgCipher: TransportCiphertext - ) { - val file = SendDmFile( - encryptedFileDataB64 = Base64.encode(encryptedBlob), - filename = filename, - fileSize = encryptedBlob.size.toLong() - ) - ApiClient.sendDm( - recipientId = recipientId, - plaintext = plaintext, - clientMessageId = jobId, - transportFiles = listOf(file), - preparedTransport = msgCipher - ) - } - - private suspend fun sendResumable( - jobId: String, - recipientId: Int, - plaintext: String, - filename: String, - encryptedBlob: ByteArray, - msgCipher: TransportCiphertext - ) { - val uploadId = settings.getString(uploadIdKey(jobId), "").ifBlank { - val init = ApiClient.initDmUpload( - filename = filename, - totalSize = encryptedBlob.size.toLong(), - recipientId = recipientId, - chunkSize = DEFAULT_CHUNK_SIZE - ) - settings.putString(uploadIdKey(jobId), init.uploadId) - init.uploadId - } - - var offset = ApiClient.getDmUploadStatus(uploadId).offset.toInt() - while (offset < encryptedBlob.size) { - val nextOffset = minOf(offset + DEFAULT_CHUNK_SIZE, encryptedBlob.size) - val chunk = encryptedBlob.copyOfRange(offset, nextOffset) - ApiClient.uploadDmChunk( - uploadId = uploadId, - offset = offset.toLong(), - dataB64 = Base64.encode(chunk) - ) - offset = nextOffset - emitProgress(jobId, ((offset.toDouble() / encryptedBlob.size.toDouble()) * 100.0).toInt()) - } - - val completed = ApiClient.completeDmUpload(uploadId) - ApiClient.sendDm( - recipientId = recipientId, - plaintext = plaintext, - clientMessageId = jobId, - uploadedFileIds = listOf(completed.fileId), - preparedTransport = msgCipher - ) - } - - private fun emitProgress(jobId: String, value: Int) { - AttachmentUploadEvents.flow.tryEmit( - AttachmentUploadProgress.InProgress( - jobId = jobId, - percent = value.coerceIn(0, 100) - ) - ) - } - - private suspend fun clearResumableState(jobId: String) { - settings.putString(uploadIdKey(jobId), "") - } - - private fun uploadIdKey(jobId: String): String = "dm_upload_id_$jobId" - - private suspend fun savePreparedTransportAndBlob( - context: Context, - jobId: String, - blob: ByteArray, - cipher: TransportCiphertext - ) { - val f = dmBlobCacheFile(context, jobId) - f.outputStream().use { it.write(blob) } - val json = JSONObject().apply { - put("clientPublicKeyB64", cipher.clientPublicKeyB64) - put("nonceB64", cipher.nonceB64) - put("ciphertextB64", cipher.ciphertextB64) - } - settings.putString(dmTransportCipherPrefsKey(jobId), json.toString()) - } - - private suspend fun loadPreparedTransportAndBlob( - context: Context, - jobId: String - ): Pair? { - val f = dmBlobCacheFile(context, jobId) - val raw = settings.getString(dmTransportCipherPrefsKey(jobId), "").ifBlank { return null } - if (!f.isFile || f.length() == 0L) return null - return try { - val o = JSONObject(raw) - val cipher = TransportCiphertext( - clientPublicKeyB64 = o.getString("clientPublicKeyB64"), - nonceB64 = o.getString("nonceB64"), - ciphertextB64 = o.getString("ciphertextB64") - ) - f.readBytes() to cipher - } catch (_: Exception) { - null - } - } - - private suspend fun clearPreparedTransportAndBlob(context: Context, jobId: String) { - dmBlobCacheFile(context, jobId).delete() - settings.putString(dmTransportCipherPrefsKey(jobId), "") } } - diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.android.kt index ded8ce5..6d066fb 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.android.kt @@ -3,13 +3,41 @@ package ru.fromchat.api.db import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver import com.pr0gramm3r101.utils.UtilsLibrary +import androidx.sqlite.db.SupportSQLiteDatabase +import java.io.File import ru.fromchat.db.MessageDatabase actual fun provideMessageDatabaseDriver(): SqlDriver { + val context = UtilsLibrary.context + val fromchatDir = File(context.cacheDir, "fromchat").apply { mkdirs() } + val dbFile = File(fromchatDir, "message_database.db") + removeLegacyDatabaseFiles(context.getDatabasePath("message_database.db")) return AndroidSqliteDriver( schema = MessageDatabase.Schema, - context = UtilsLibrary.context, - name = "message_database.db" + context = context, + name = dbFile.absolutePath, + callback = DiffOnlyDatabaseCallback(), ) } +/** Drops the pre-cache-dir DB; message cache lives under cache only. */ +private fun removeLegacyDatabaseFiles(legacyDb: File) { + if (!legacyDb.exists()) return + runCatching { legacyDb.delete() } + runCatching { File("${legacyDb.path}-journal").delete() } + runCatching { File("${legacyDb.path}-wal").delete() } + runCatching { File("${legacyDb.path}-shm").delete() } +} + +/** + * SQLDelight [user_version] is not used for migrations; [ensureMessageDatabaseSchema] diffs structure. + */ +private class DiffOnlyDatabaseCallback : AndroidSqliteDriver.Callback(MessageDatabase.Schema) { + override fun onCreate(db: SupportSQLiteDatabase) { + // Schema is created by structural diff on first open. + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { + // No version-based migration. + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.android.kt new file mode 100644 index 0000000..84044c5 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.android.kt @@ -0,0 +1,5 @@ +package ru.fromchat.api.db + +private val lock = Any() + +internal actual fun withMessageDatabaseLock(block: () -> T): T = synchronized(lock, block) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/MediaUploadForegroundHelper.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/MediaUploadForegroundHelper.kt new file mode 100644 index 0000000..4e710be --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/MediaUploadForegroundHelper.kt @@ -0,0 +1,72 @@ +package ru.fromchat.api.outbox + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo +import com.pr0gramm3r101.utils.UtilsLibrary +import org.jetbrains.compose.resources.getString +import ru.fromchat.Res +import ru.fromchat.notif_media_upload_channel_name +import ru.fromchat.notif_media_upload_progress +import ru.fromchat.notif_media_upload_text +import ru.fromchat.notif_media_upload_title + +private const val CHANNEL_ID = "fromchat_media_upload" +private const val NOTIFICATION_ID = 0xFC10 + +object MediaUploadForegroundHelper { + suspend fun foregroundInfo( + context: Context = UtilsLibrary.context, + percent: Int? = null, + filename: String? = null, + ): ForegroundInfo { + val channelName = getString(Res.string.notif_media_upload_channel_name) + val title = getString(Res.string.notif_media_upload_title) + val defaultText = getString(Res.string.notif_media_upload_text) + ensureChannel(context, channelName) + val contentText = when { + percent != null && !filename.isNullOrBlank() -> + getString(Res.string.notif_media_upload_progress, percent.coerceIn(0, 100), filename) + percent != null -> "$percent%" + else -> defaultText + } + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(title) + .setContentText(contentText) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(Notification.CATEGORY_PROGRESS) + when (val p = percent?.coerceIn(0, 100)) { + null -> builder.setProgress(100, 0, true) + else -> builder.setProgress(100, p, false) + } + val notification = builder.build() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + ForegroundInfo(NOTIFICATION_ID, notification) + } + } + + private fun ensureChannel(context: Context, channelName: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) ?: return + val channel = NotificationChannel( + CHANNEL_ID, + channelName, + NotificationManager.IMPORTANCE_LOW, + ) + manager.createNotificationChannel(channel) + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.android.kt new file mode 100644 index 0000000..6b64dff --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.android.kt @@ -0,0 +1,39 @@ +package ru.fromchat.api.outbox + +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import com.pr0gramm3r101.utils.UtilsLibrary + +actual fun scheduleOutboxProcessing(instanceId: String) { + val id = instanceId.trim() + if (id.isEmpty()) return + val request = OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putString(OutboxSendWorker.KEY_INSTANCE_ID, id) + .build(), + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + WorkManager.getInstance(UtilsLibrary.context).enqueueUniqueWork( + "outbox-send-$id", + ExistingWorkPolicy.APPEND_OR_REPLACE, + request, + ) +} + +actual fun cancelOutboxProcessing(instanceId: String) { + val id = instanceId.trim() + if (id.isEmpty()) return + WorkManager.getInstance(UtilsLibrary.context).cancelUniqueWork("outbox-send-$id") +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxSendWorker.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxSendWorker.kt new file mode 100644 index 0000000..934e7ea --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/outbox/OutboxSendWorker.kt @@ -0,0 +1,63 @@ +package ru.fromchat.api.outbox + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import ru.fromchat.api.AttachmentUploadNotifier +import ru.fromchat.api.AttachmentUploadProgress +import ru.fromchat.core.cache.CacheContext + +class OutboxSendWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = coroutineScope { + val instanceId = inputData.getString(KEY_INSTANCE_ID)?.trim().orEmpty() + .ifEmpty { CacheContext.activeInstanceId.value.trim() } + if (instanceId.isEmpty()) return@coroutineScope Result.success() + val hasMedia = DmAttachmentOutboxHandler.hasPendingAttachmentWork(instanceId) + val progressJob = if (hasMedia) { + launch { + AttachmentUploadNotifier.progressFlow.collect { progress -> + when (progress) { + is AttachmentUploadProgress.InProgress -> { + setForeground( + MediaUploadForegroundHelper.foregroundInfo( + percent = progress.percent, + filename = progress.filename, + ), + ) + } + is AttachmentUploadProgress.Pending -> { + setForeground( + MediaUploadForegroundHelper.foregroundInfo( + percent = 0, + filename = progress.filename, + ), + ) + } + else -> Unit + } + } + } + } else { + null + } + if (hasMedia) { + setForeground(MediaUploadForegroundHelper.foregroundInfo(percent = 0)) + } + val allOk = try { + OutgoingMessageCoordinator.drainOutboxForInstance(instanceId) + } finally { + progressJob?.cancel() + } + Result.success() + } + + companion object { + const val KEY_INSTANCE_ID = "instance_id" + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.android.kt new file mode 100644 index 0000000..d725fd0 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.android.kt @@ -0,0 +1,12 @@ +package ru.fromchat.core.cache + +import com.pr0gramm3r101.utils.UtilsLibrary +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +actual suspend fun wipeFromChatCacheDirectory() { + withContext(Dispatchers.IO) { + File(UtilsLibrary.context.cacheDir, "fromchat").deleteRecursively() + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.android.kt new file mode 100644 index 0000000..12fc3ba --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.android.kt @@ -0,0 +1,30 @@ +package ru.fromchat.core.cache + +import com.pr0gramm3r101.utils.UtilsLibrary +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import ru.fromchat.api.db.MessageDatabaseProvider +import ru.fromchat.core.cache.wipeFromChatCacheDirectory + +private const val GENERATION_FILE = ".generation" + +actual suspend fun ensureFromChatCacheGeneration() { + withContext(Dispatchers.IO) { + val root = File(UtilsLibrary.context.cacheDir, "fromchat") + val marker = File(root, GENERATION_FILE) + if (marker.isFile) return@withContext + MessageDatabaseProvider.closeAndReset() + wipeFromChatCacheDirectory() + marker.parentFile?.mkdirs() + marker.writeText("1\n") + } +} + +actual suspend fun writeFromChatCacheGeneration() { + withContext(Dispatchers.IO) { + val root = File(UtilsLibrary.context.cacheDir, "fromchat") + root.mkdirs() + File(root, GENERATION_FILE).writeText("1\n") + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.android.kt new file mode 100644 index 0000000..b013f87 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.android.kt @@ -0,0 +1,108 @@ +package ru.fromchat.core.cache + +import android.net.Uri +import com.pr0gramm3r101.utils.UtilsLibrary +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private fun uploadDir(instanceId: String): File { + val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_") + return File(UtilsLibrary.context.cacheDir, "fromchat/instances/$safe/uploads").apply { mkdirs() } +} + +private fun blobFile(instanceId: String, clientMessageId: String): File { + val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_") + return File(uploadDir(instanceId), "$safeId.enc") +} + +private fun cipherFile(instanceId: String, clientMessageId: String): File { + val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_") + return File(uploadDir(instanceId), "$safeId.cipher.json") +} + +private fun sourceFile(instanceId: String, clientMessageId: String): File { + val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_") + return File(uploadDir(instanceId), "$safeId.source") +} + +actual suspend fun stageOutboundFileForUpload( + instanceId: String, + clientMessageId: String, + sourceUri: String, +): StagedOutboundFile = withContext(Dispatchers.IO) { + 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) } + } + } + } + StagedOutboundFile(uri = destUri, sizeBytes = dest.length().coerceAtLeast(0L)) +} + +actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray = + withContext(Dispatchers.IO) { + when { + fileUri.startsWith("content://") -> + UtilsLibrary.context.contentResolver.openInputStream(Uri.parse(fileUri))?.use { it.readBytes() } + ?: throw OutboundFileUnavailableException("Failed to read file from URI") + fileUri.startsWith("file://") -> { + val path = Uri.parse(fileUri).path ?: throw OutboundFileUnavailableException("Invalid file URI") + val file = File(path) + if (!file.isFile) throw OutboundFileUnavailableException("File no longer exists") + file.readBytes() + } + else -> { + val file = File(fileUri) + if (!file.isFile) throw OutboundFileUnavailableException("File no longer exists") + file.readBytes() + } + } + } + +actual suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray) { + withContext(Dispatchers.IO) { + blobFile(instanceId, clientMessageId).outputStream().use { it.write(bytes) } + } +} + +actual suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? = + withContext(Dispatchers.IO) { + 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) { + withContext(Dispatchers.IO) { + cipherFile(instanceId, clientMessageId).writeText(json) + } +} + +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() } + } + +actual suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String) { + withContext(Dispatchers.IO) { + blobFile(instanceId, clientMessageId).delete() + cipherFile(instanceId, clientMessageId).delete() + sourceFile(instanceId, clientMessageId).delete() + } +} + +actual suspend fun clearUploadSecretsOnly(instanceId: String, clientMessageId: String) { + withContext(Dispatchers.IO) { + blobFile(instanceId, clientMessageId).delete() + cipherFile(instanceId, clientMessageId).delete() + } +} 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 a7a32b6..8396104 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 @@ -34,17 +34,27 @@ actual object DmCrypto { actual suspend fun decryptEnvelope( ivB64: String, ciphertextB64: String, - mek: ByteArray + mek: ByteArray, ): ByteArray = withContext(Dispatchers.Default) { - require(mek.size == AES_KEY_SIZE) { "MEK must be 32 bytes" } - val iv = Base64.decode(ivB64) val ciphertext = Base64.decode(ciphertextB64) + decryptAesGcmRaw(iv, ciphertext, mek) + } + actual suspend fun decryptAesGcm( + ivB64: String, + ciphertext: ByteArray, + mek: ByteArray, + ): ByteArray = withContext(Dispatchers.Default) { + val iv = Base64.decode(ivB64) + decryptAesGcmRaw(iv, ciphertext, mek) + } + + 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" } require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" } - - try { + return try { BackupCryptoPlatform.aesGcmDecrypt(mek, iv, ciphertext) } catch (e: GeneralSecurityException) { throw DmCiphertextCorruptedException(cause = e) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt index 7d990ad..a9d3134 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt @@ -3,6 +3,7 @@ package ru.fromchat.ui.chat import android.graphics.BitmapFactory import android.net.Uri import android.provider.OpenableColumns +import androidx.exifinterface.media.ExifInterface import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -22,12 +23,100 @@ actual fun getFilenameFromUri(uri: String): String { actual suspend fun getImageAspectRatio(uri: String): Float? { val context = com.pr0gramm3r101.utils.UtilsLibrary.context - context.contentResolver.openInputStream(Uri.parse(uri))?.use { stream -> + val parsed = Uri.parse(uri) + val orientation = when { + parsed.scheme == "content" || parsed.scheme == "file" -> { + runCatching { + context.contentResolver.openInputStream(parsed)?.use { stream -> + ExifInterface(stream).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + } + }.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL + } + else -> { + val path = parsed.path + if (path != null) { + runCatching { + ExifInterface(path).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + }.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL + } else { + ExifInterface.ORIENTATION_NORMAL + } + } + } + context.contentResolver.openInputStream(parsed)?.use { stream -> val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeStream(stream, null, options) - val w = options.outWidth - val h = options.outHeight - if (w > 0 && h > 0) return w.toFloat() / h + var w = options.outWidth + var h = options.outHeight + if (w <= 0 || h <= 0) return null + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_ROTATE_270, + ExifInterface.ORIENTATION_TRANSPOSE, + ExifInterface.ORIENTATION_TRANSVERSE, + -> { + val swap = w + w = h + h = swap + } + } + return w.toFloat() / h.toFloat() + } + return null +} + +actual suspend fun getImageDimensions(uri: String): Pair? { + val context = com.pr0gramm3r101.utils.UtilsLibrary.context + val parsed = Uri.parse(uri) + val orientation = when { + parsed.scheme == "content" || parsed.scheme == "file" -> { + runCatching { + context.contentResolver.openInputStream(parsed)?.use { stream -> + ExifInterface(stream).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + } + }.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL + } + else -> { + val path = parsed.path + if (path != null) { + runCatching { + ExifInterface(path).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + }.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL + } else { + ExifInterface.ORIENTATION_NORMAL + } + } + } + context.contentResolver.openInputStream(parsed)?.use { stream -> + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeStream(stream, null, options) + var w = options.outWidth + var h = options.outHeight + if (w <= 0 || h <= 0) return null + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_ROTATE_270, + ExifInterface.ORIENTATION_TRANSPOSE, + ExifInterface.ORIENTATION_TRANSVERSE, + -> { + val swap = w + w = h + h = swap + } + } + return w to h } return null } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.android.kt new file mode 100644 index 0000000..801da35 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.android.kt @@ -0,0 +1,144 @@ +package ru.fromchat.ui.chat + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.util.LruCache +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.exifinterface.media.ExifInterface + +actual object PlatformDecodedBitmapCache { + private val cache: LruCache = object : LruCache(maxCacheBytes()) { + override fun sizeOf(key: String, value: ImageBitmap): Int = + value.width * value.height * 4 + } + + actual fun get(key: String): ImageBitmap? = cache.get(key) + + actual fun put(key: String, bitmap: ImageBitmap) { + cache.put(key, bitmap) + } + + actual fun remove(key: String) { + cache.remove(key) + } + + actual fun evictPrefix(prefix: String) { + val snapshot = cache.snapshot() + for (entryKey in snapshot.keys) { + if (entryKey.startsWith(prefix)) { + cache.remove(entryKey) + } + } + } + + private fun maxCacheBytes(): Int = + (Runtime.getRuntime().maxMemory() / 8).toInt().coerceAtLeast(4 * 1024 * 1024) +} + +actual fun decodeLocalImageFile(absolutePath: String, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? = + decodeSampledFromFile(absolutePath, reqWidthPx, reqHeightPx)?.asImageBitmap() + +actual fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? = + decodeSampledFromBytes(bytes, reqWidthPx, reqHeightPx)?.asImageBitmap() + +private fun decodeSampledFromFile(path: String, reqWidthPx: Int, reqHeightPx: Int): Bitmap? { + val orientation = readExifOrientation(path) + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val (orientedW, orientedH) = orientedDimensions(bounds.outWidth, bounds.outHeight, orientation) + val sampleSize = calculateInSampleSize(orientedW, orientedH, reqWidthPx, reqHeightPx) + val options = BitmapFactory.Options().apply { + inSampleSize = sampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + val decoded = BitmapFactory.decodeFile(path, options) ?: return null + val oriented = applyExifOrientation(decoded, orientation) + return scaleBitmapToFitWithin(oriented, reqWidthPx, reqHeightPx) +} + +private fun decodeSampledFromBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val sampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, reqWidthPx, reqHeightPx) + val options = BitmapFactory.Options().apply { + inSampleSize = sampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null + return scaleBitmapToFitWithin(decoded, reqWidthPx, reqHeightPx) +} + +/** Downscale only — never upscale or crop; [ContentScale.Crop] on the tile handles fill. */ +private fun scaleBitmapToFitWithin(bitmap: Bitmap, reqWidthPx: Int, reqHeightPx: Int): Bitmap { + if (bitmap.width <= reqWidthPx && bitmap.height <= reqHeightPx) return bitmap + val scale = minOf( + reqWidthPx.toFloat() / bitmap.width.toFloat(), + reqHeightPx.toFloat() / bitmap.height.toFloat(), + ) + if (scale >= 1f) return bitmap + val dstW = (bitmap.width * scale).toInt().coerceAtLeast(1) + val dstH = (bitmap.height * scale).toInt().coerceAtLeast(1) + return Bitmap.createScaledBitmap(bitmap, dstW, dstH, true) +} + +private fun readExifOrientation(path: String): Int = + runCatching { + ExifInterface(path).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + }.getOrDefault(ExifInterface.ORIENTATION_NORMAL) + +private fun orientedDimensions(width: Int, height: Int, orientation: Int): Pair = + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_ROTATE_270, + ExifInterface.ORIENTATION_TRANSPOSE, + ExifInterface.ORIENTATION_TRANSVERSE, + -> height to width + else -> width to height + } + +private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap { + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.postRotate(90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.postRotate(270f) + matrix.postScale(-1f, 1f) + } + else -> return bitmap + } + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + ?: bitmap +} + +/** Android docs: largest inSampleSize where both dimensions stay >= requested. */ +private fun calculateInSampleSize( + width: Int, + height: Int, + reqWidthPx: Int, + reqHeightPx: Int, +): Int { + var inSampleSize = 1 + if (height > reqHeightPx || width > reqWidthPx) { + var halfHeight = height / 2 + var halfWidth = width / 2 + while (halfHeight / inSampleSize >= reqHeightPx && halfWidth / inSampleSize >= reqWidthPx) { + inSampleSize *= 2 + } + } + return inSampleSize.coerceAtLeast(1) +} diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 47030a6..df8965d 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -64,16 +64,25 @@ Активный звонок Видеозвонок Камера и микрофон остаются включёнными, пока вы вне приложения + Загрузка медиа + Отправка вложения + Загрузка продолжается в фоне Вы Человек %1$d Это сообщение не удалось показать. (изменено) Ответ %1$s Сообщение не показывается + Не удалось загрузить + Повторить + Повторить загрузку изображения Правка сообщения Ответить Изменить Удалить + Копировать + Отменить + %1$d%% · %2$s Сохранить Закрыть Убрать @@ -152,6 +161,16 @@ Сбросить Сбросить настройки? Будут восстановлены адрес сервера и порты по умолчанию. + Сервер не предоставляет корректный ID экземпляра. Подключение невозможно. + Сервер не ответил вовремя. Повторите попытку. + Проверка… + Сессия завершена: не удалось определить ID экземпляра сервера. + + Очистить локальные данные + Удаляет кэш сообщений, медиа и отложенные отправки на этом устройстве. Аккаунт на сервере не затрагивается. + Очистить локальные данные? + Будут удалены офлайн-сообщения и загрузки для всех экземпляров сервера на этом устройстве. Неотправленные сообщения из очереди будут потеряны. + Локальные данные очищены Сохранить и продолжить Сменить сервер Подключиться к альтернативному серверу FromChat и выйти из аккаунта. diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index e34eb48..f58605a 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -75,6 +75,9 @@ Ongoing call Video call Camera and microphone stay active while you’re away + Media upload + Sending attachment + Upload continues in the background You @@ -83,13 +86,19 @@ (edited) Reply to %1$s Can’t show this message + Failed to load + Retry + Retry loading image Edit message Reply Edit Delete + Copy + Cancel Save + %1$d%% · %2$s Close @@ -179,6 +188,17 @@ Reset Reset to defaults? This restores the default server and port settings. + This server does not provide a valid instance ID. Cannot connect. + Server did not respond in time. Try again. + Checking… + Session ended: server instance ID could not be resolved. + + + Clear local data + Removes cached messages, media, and pending sends on this device. Your account on the server is not affected. + Clear local data? + All offline messages and downloads for every server instance on this device will be deleted. Unsent messages in the outbox will be lost. + Local data cleared Save and continue 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 55c2d26..5362667 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -18,6 +18,14 @@ 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 import io.ktor.client.request.post @@ -36,6 +44,8 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToJsonElement import ru.fromchat.core.Settings import ru.fromchat.core.config.Config +import ru.fromchat.core.instance.InstanceIdGuard +import ru.fromchat.api.db.InstanceRegistryStore import ru.fromchat.ui.chat.PublicChatPanelCache import ru.fromchat.ui.dm.DmPanelCache import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable @@ -150,6 +160,12 @@ object ApiClient { // - 403 => forbidden, keep session intact (used for read-only/suspension workflows). HttpResponseValidator { validateResponse { response -> + val instanceHeader = response.headers[InstanceIdGuard.INSTANCE_ID_HEADER] + runCatching { + Config.serverConfig.value?.let { cfg -> + InstanceIdGuard.onResponseHeader(instanceHeader, cfg) + } + } if (response.status.value == 401) { token = null user = null @@ -194,6 +210,20 @@ object ApiClient { defaultRequest { header("User-Agent", userAgent) } + HttpResponseValidator { + validateResponse { response -> + val instanceHeader = response.headers[InstanceIdGuard.INSTANCE_ID_HEADER] + runCatching { + InstanceIdGuard.onResponseHeader(instanceHeader) + } + if (response.status.value !in (200..299) + 101) { + throw ClientRequestException( + response, + response.status.description, + ) + } + } + } } } @@ -222,6 +252,7 @@ object ApiClient { val id = fetchServerInstanceId(Config.apiBaseUrl) if (id.isNotEmpty()) { Settings.lastKnownServerInstanceId = id + InstanceRegistryStore.registerInstanceEncountered(id) } } } @@ -546,16 +577,147 @@ object ApiClient { * Fetch encrypted file bytes. Path is e.g. "/uploads/files/encrypted/xxx.jpg" or "/api/uploads/files/encrypted/xxx.jpg". * Backend may return path with /api prefix; apiBaseUrl already includes /api, so we avoid double /api. */ - suspend fun fetchEncryptedFile(path: String): ByteArray { - val url = when { - path.startsWith("http") -> path - path.startsWith("/api") -> { - val serverBase = Config.apiBaseUrl.removeSuffix("/api") - "$serverBase$path" - } - else -> "${Config.apiBaseUrl}$path" + fun encryptedFileUrl(path: String): String = when { + path.startsWith("http") -> path + path.startsWith("/api") -> { + val serverBase = Config.apiBaseUrl.removeSuffix("/api") + "$serverBase$path" + } + else -> "${Config.apiBaseUrl}$path" + } + + suspend fun fetchEncryptedFile(path: String): ByteArray = + fetchEncryptedFileResumable(path, resumeKey = null, onProgress = null) + + /** + * Downloads encrypted file bytes with optional resume ([resumeKey] partial on disk) and progress. + */ + suspend fun fetchEncryptedFileResumable( + path: String, + resumeKey: String?, + onProgress: ((percent: Int) -> Unit)?, + ): ByteArray { + val url = encryptedFileUrl(path) + val partialPath = resumeKey?.let { partialEncryptedDownloadPath(it) } + val prefix = partialPath?.let { readPartialEncryptedBytes(it) } ?: ByteArray(0) + val offset = prefix.size + + onProgress?.invoke(if (offset > 0) percentForBytes(offset, offset.coerceAtLeast(1)) else 1) + + val response = http.get(url) { + if (offset > 0) { + header(HttpHeaders.Range, "bytes=$offset-") + } + } + + return when (response.status) { + HttpStatusCode.PartialContent -> { + readDownloadBody( + response = response, + prefix = prefix, + partialPath = partialPath, + onProgress = onProgress, + ) + } + HttpStatusCode.OK -> { + if (offset > 0) { + partialPath?.let { PlatformFileSystem.delete(it) } + } + readDownloadBody( + response = response, + prefix = if (offset > 0) ByteArray(0) else prefix, + partialPath = partialPath, + onProgress = onProgress, + ) + } + else -> { + val bytes = response.body() + onProgress?.invoke(100) + partialPath?.let { PlatformFileSystem.delete(it) } + bytes + } + } + } + + private suspend fun readDownloadBody( + response: HttpResponse, + prefix: ByteArray, + partialPath: String?, + onProgress: ((percent: Int) -> Unit)?, + ): ByteArray { + val channel = response.bodyAsChannel() + var buffer = prefix + var received = prefix.size + val totalBytes = responseTotalBytes(response, received) + + while (!channel.isClosedForRead) { + val packet = channel.readRemaining(16 * 1024) + if (packet.isEmpty) break + val chunk = packet.readBytes() + if (chunk.isEmpty()) continue + buffer = buffer + chunk + received += chunk.size + partialPath?.let { PlatformFileSystem.writeBytes(it, buffer) } + onProgress?.invoke( + if (totalBytes != null && totalBytes > 0) { + percentForBytes(received, totalBytes) + } else { + (received / 32_768).coerceIn(1, 99) + }, + ) + } + + onProgress?.invoke(100) + partialPath?.let { PlatformFileSystem.delete(it) } + return buffer + } + + 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 + } + } + + /** Drops a partial encrypted download so the next attempt starts clean. */ + fun clearPartialEncryptedDownload(resumeKey: String) { + partialEncryptedDownloadPath(resumeKey)?.let { path -> + runCatching { PlatformFileSystem.delete(path) } + } + } + + private fun partialEncryptedDownloadPath(resumeKey: String): String? { + val base = PlatformFileSystem.getAppCacheDirectory() + if (base.isEmpty()) return null + val dir = "$base/encrypted_downloads" + PlatformFileSystem.ensureDirectory(dir) + 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 percentForBytes(received: Int, total: Int): Int { + if (received <= 0 || total <= 0) return 0 + val raw = ((received.toDouble() / total.toDouble()) * 100.0).toInt() + return when { + raw <= 0 -> 1 + raw >= 100 -> 99 + else -> raw } - return http.get(url).body() } /** @@ -812,6 +974,25 @@ object ApiClient { ) } + suspend fun deleteDm(messageId: Int, recipientId: Int) { + if (_suspensionState.value.isSuspended) return + WebSocketManager.send( + WebSocketMessage( + type = "dmDelete", + credentials = WebSocketCredentials( + scheme = "Bearer", + credentials = getTokenSafely() + ), + data = json.encodeToJsonElement( + WebSocketDeleteDmRequest( + id = messageId, + recipientId = recipientId, + ) + ) + ) + ) + } + suspend fun sendTyping() { if (_suspensionState.value.isSuspended) return runCatching { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt new file mode 100644 index 0000000..6585703 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentDownloadNotifier.kt @@ -0,0 +1,117 @@ +package ru.fromchat.api + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.fromchat.ui.chat.AttachmentMediaLog +import ru.fromchat.ui.chat.DecryptedImageCache + +sealed class AttachmentDownloadProgress { + data class InProgress(val storageKey: String, val percent: Int) : AttachmentDownloadProgress() + data class Success( + val storageKey: String, + val messageId: Int = 0, + ) : AttachmentDownloadProgress() + data class Failed(val storageKey: String, val error: String) : AttachmentDownloadProgress() +} + +/** + * In-chat download/decrypt progress (no system notification). + * [progressPercentByKey] is the source of truth for UI; [progressFlow] is for one-shot side effects. + */ +object AttachmentDownloadNotifier { + private val _progressFlow = MutableSharedFlow(extraBufferCapacity = 64) + val progressFlow: SharedFlow = _progressFlow + + private val _progressPercentByKey = MutableStateFlow>(emptyMap()) + val progressPercentByKey: StateFlow> = _progressPercentByKey.asStateFlow() + + private val _failedKeys = MutableStateFlow>(emptySet()) + val failedKeys: StateFlow> = _failedKeys.asStateFlow() + + private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + fun emit( + progress: AttachmentDownloadProgress, + messageLabel: String? = null, + messageId: Int = 0, + fileIndex: Int = 0, + clientMessageId: String? = null, + ) { + val msg = AttachmentMediaLog.messageLabel(messageLabel) + val primaryKey = when (progress) { + is AttachmentDownloadProgress.InProgress -> progress.storageKey + is AttachmentDownloadProgress.Success -> progress.storageKey + is AttachmentDownloadProgress.Failed -> progress.storageKey + } + val mirrorKeys = DecryptedImageCache.progressLookupKeys( + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ).ifEmpty { listOf(primaryKey) } + when (progress) { + is AttachmentDownloadProgress.InProgress -> { + if (progress.percent == 1 || progress.percent % 15 == 0 || progress.percent >= 95) { + AttachmentMediaLog.download( + "progress", + "key" to progress.storageKey, + "pct" to progress.percent, + "msg" to msg, + "mirror" to mirrorKeys.joinToString(","), + ) + } + } + is AttachmentDownloadProgress.Success -> + AttachmentMediaLog.download( + "success", + "key" to progress.storageKey, + "msgId" to progress.messageId, + "msg" to msg, + ) + is AttachmentDownloadProgress.Failed -> + AttachmentMediaLog.download( + "failed", + "key" to progress.storageKey, + "err" to progress.error, + "msg" to msg, + ) + } + when (progress) { + is AttachmentDownloadProgress.InProgress -> { + val pct = progress.percent.coerceIn(1, 100) + _progressPercentByKey.update { map -> + map + mirrorKeys.associateWith { pct } + } + } + is AttachmentDownloadProgress.Success -> { + _progressPercentByKey.update { map -> + map + mirrorKeys.associateWith { 100 } + } + } + is AttachmentDownloadProgress.Failed -> { + _progressPercentByKey.update { map -> map - mirrorKeys.toSet() } + _failedKeys.update { keys -> keys + mirrorKeys.toSet() } + } + } + mainScope.launch { + _progressFlow.emit(progress) + } + } + + fun clearProgress(messageId: Int, fileIndex: Int, clientMessageId: String? = null) { + val keys = DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId).toSet() + _progressPercentByKey.update { map -> map - keys } + _failedKeys.update { failed -> failed - keys } + } + + fun isFailed(messageId: Int, fileIndex: Int, clientMessageId: String? = null): Boolean = + DecryptedImageCache.progressLookupKeys(messageId, fileIndex, clientMessageId) + .any { it in _failedKeys.value } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadNotifier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadNotifier.kt new file mode 100644 index 0000000..589e227 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadNotifier.kt @@ -0,0 +1,52 @@ +package ru.fromchat.api + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import ru.fromchat.ui.chat.AttachmentMediaLog + +/** Shared upload/download progress events (outbox worker + UI + decrypt pipeline). */ +object AttachmentUploadNotifier { + private val _progressFlow = MutableSharedFlow(extraBufferCapacity = 64) + val progressFlow: SharedFlow = _progressFlow + private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + fun emit(progress: AttachmentUploadProgress, messageLabel: String? = null) { + val msg = AttachmentMediaLog.messageLabel(messageLabel) + when (progress) { + is AttachmentUploadProgress.Pending -> + AttachmentMediaLog.upload( + "pending", + "job" to progress.jobId, + "file" to progress.filename, + "msg" to msg, + ) + is AttachmentUploadProgress.InProgress -> { + if (progress.percent == 1 || progress.percent % 10 == 0 || progress.percent >= 95) { + AttachmentMediaLog.upload( + "progress", + "job" to progress.jobId, + "pct" to progress.percent, + "file" to progress.filename, + "msg" to msg, + ) + } + } + is AttachmentUploadProgress.Success -> + AttachmentMediaLog.upload("success", "job" to progress.jobId, "msg" to msg) + is AttachmentUploadProgress.Failed -> + AttachmentMediaLog.upload( + "failed", + "job" to progress.jobId, + "err" to progress.error, + "msg" to msg, + ) + } + mainScope.launch { + _progressFlow.emit(progress) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.kt index fe7210a..7a80efb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.kt @@ -13,7 +13,7 @@ data class AttachmentUploadJob( sealed class AttachmentUploadProgress { data class Pending(val jobId: String, val filename: String) : AttachmentUploadProgress() - data class InProgress(val jobId: String, val percent: Int) : AttachmentUploadProgress() + data class InProgress(val jobId: String, val percent: Int, val filename: String? = null) : AttachmentUploadProgress() data class Success(val jobId: String) : AttachmentUploadProgress() data class Failed(val jobId: String, val error: String) : AttachmentUploadProgress() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ClientMessageId.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ClientMessageId.kt new file mode 100644 index 0000000..0532493 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ClientMessageId.kt @@ -0,0 +1,14 @@ +package ru.fromchat.api + +import korlibs.crypto.SHA256 +import kotlin.random.Random +import kotlin.time.Clock + +/** + * Opaque id for optimistic send / server ack matching. + * SHA-256 hex of send-time material (epoch ms + nonce); echoed to the sender only on [dmNew]. + */ +fun generateClientMessageId(): String { + val material = "${Clock.System.now().toEpochMilliseconds()}:${Random.nextInt()}" + return SHA256.digest(material.encodeToByteArray()).hexLower +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageOrdering.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageOrdering.kt new file mode 100644 index 0000000..9f2d78a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageOrdering.kt @@ -0,0 +1,18 @@ +package ru.fromchat.api + +/** + * Chat list order: confirmed messages by time, then outgoing queued (negative id) at the bottom. + * Avoids optimistic rows jumping mid-thread when client clock lags the server. + */ +internal fun sortMessagesForChatDisplay(messages: List): List { + if (messages.size <= 1) return messages + val (pending, confirmed) = messages.partition { it.id < 0 } + val comparator = compareBy( + { messageSortEpochMillis(it) }, + { it.id.toLong() }, + ) + return confirmed.sortedWith(comparator) + pending.sortedWith(comparator) +} + +internal fun messageSortEpochMillis(message: Message): Long = + parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageTimestamps.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageTimestamps.kt new file mode 100644 index 0000000..2334121 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/MessageTimestamps.kt @@ -0,0 +1,55 @@ +package ru.fromchat.api + +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock + +private val OFFSET_SUFFIX = Regex("""[+-]\d{2}:\d{2}$""") + +/** ISO-8601 UTC instant for new/queued messages. */ +fun nowMessageTimestampIso(): String = Clock.System.now().toString() + +/** + * Parse message timestamps from server or client. + * Zone-less ISO strings are treated as UTC, then shown in the device zone. + */ +internal fun parseMessageInstant(timestamp: String): Instant? { + val raw = timestamp.trim() + if (raw.isEmpty()) return null + val normalized = raw.replace(' ', 'T') + parseInstantOrNull(normalized)?.let { return it } + if (!hasExplicitOffset(normalized)) { + parseInstantOrNull("${normalized}Z")?.let { return it } + } + return null +} + +internal fun parseMessageTimestampMillis(timestamp: String): Long? = + parseMessageInstant(timestamp)?.toEpochMilliseconds() + +/** HH:mm in the device time zone (bubble footer). */ +internal fun formatMessageTimeLocal(timestamp: String): String { + val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return "" + return "${local.hour.toString().padStart(2, '0')}:${local.minute.toString().padStart(2, '0')}" +} + +/** MM/dd/yyyy HH:mm in the device time zone (fullscreen header). */ +internal fun formatMessageDateTimeLocal(timestamp: String): String { + val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return timestamp.trim() + val month = local.monthNumber.toString().padStart(2, '0') + val day = local.dayOfMonth.toString().padStart(2, '0') + val hour = local.hour.toString().padStart(2, '0') + val minute = local.minute.toString().padStart(2, '0') + return "$month/$day/${local.year} $hour:$minute" +} + +private fun parseInstantOrNull(value: String): Instant? = + runCatching { Instant.parse(value) }.getOrNull() + +private fun hasExplicitOffset(value: String): Boolean = + value.endsWith('Z', ignoreCase = true) || OFFSET_SUFFIX.containsMatchIn(value) + +private fun Instant.toDeviceLocal(): LocalDateTime = + toLocalDateTime(TimeZone.currentSystemDefault()) 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 e41e026..e8005f7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -397,6 +397,19 @@ data class WebSocketDeleteMessageRequest( val message_id: Int ) +@Serializable +data class WebSocketDeleteDmRequest( + val id: Int, + @SerialName("recipientId") val recipientId: Int, +) + +@Serializable +data class DmDeletedData( + val id: Int, + @SerialName("senderId") val senderId: Int, + @SerialName("recipientId") val recipientId: Int? = null, +) + @Serializable data class DmTypingData( @SerialName("recipientId") val recipientId: Int diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/OptimisticMessageIds.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/OptimisticMessageIds.kt new file mode 100644 index 0000000..f7b6204 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/OptimisticMessageIds.kt @@ -0,0 +1,10 @@ +package ru.fromchat.api + +/** Stable negative row id for an optimistic / outbox [clientMessageId]. */ +fun optimisticMessageIdForClientMessageId(clientMessageId: String): Int { + val hc = clientMessageId.hashCode() + val absHc = if (hc == Int.MIN_VALUE) Int.MAX_VALUE else kotlin.math.abs(hc) + return -(if (absHc == 0) 1 else absHc) +} + +fun Message.isQueuedOutbound(): Boolean = id < 0 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt index c062d5e..76ef66b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt @@ -1,14 +1,12 @@ package ru.fromchat.api -import com.pr0gramm3r101.utils.settings.settings 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 kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.json.Json +import ru.fromchat.api.db.ProfileCacheStore import kotlin.concurrent.Volatile /** @@ -29,21 +27,16 @@ fun UserProfile.visibleDisplayName(currentUserId: Int? = null): String? = displayName?.trim()?.ifEmpty { null } ?: visibleUsername(currentUserId) /** - * In-memory profile cache with disk persistence. [get] reads a volatile snapshot (lock-free). - * [put] copy-on-writes the map and schedules an async flush of the full list to settings. + * In-memory profile cache for the active [ru.fromchat.core.cache.CacheContext] instance, + * backed by SQLDelight [profile_cache] per instance partition. */ object ProfileCache { - private const val SETTINGS_KEY = "profile_cache_profiles_v1" - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - encodeDefaults = true - } - @Volatile private var profiles: Map = emptyMap() + @Volatile + private var loadedInstanceId: String = "" + private val persistMutex = Mutex() private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -60,24 +53,26 @@ object ProfileCache { } val cur = profiles profiles = cur + (profile.id to profile) - schedulePersist() + val instanceId = loadedInstanceId + if (instanceId.isNotEmpty()) { + ioScope.launch { + runCatching { ProfileCacheStore.put(instanceId, profile) } + } + } } - /** - * Removes one user from the cache and persists. Does not clear full API profiles unless - * the caller only invokes this for preview cleanup flows. - */ fun remove(userId: Int) { val cur = profiles if (userId !in cur) return profiles = cur - userId - schedulePersist() + val instanceId = loadedInstanceId + if (instanceId.isNotEmpty()) { + ioScope.launch { + runCatching { ProfileCacheStore.remove(instanceId, userId) } + } + } } - /** - * Drops [isClientPreviewOnly] entries that have no usable identity (blank username and - * display name). Avoids persisting or showing stale rows from failed loads / bad merges. - */ fun evictUnusableClientPreview(userId: Int) { val p = get(userId) ?: return if (!p.isClientPreviewOnly) return @@ -86,11 +81,6 @@ object ProfileCache { if (!hasIdentity) remove(userId) } - /** - * Seeds or refreshes a lightweight profile from a DM conversations list [User]. - * Skips when a full `/user/...` profile is already cached. - * Does not write a preview when the API user has no non-blank username (avoids caching empty identity). - */ fun mergeFromDmUser(user: User) { val existing = get(user.id) if (existing != null && !existing.isClientPreviewOnly) return @@ -116,8 +106,8 @@ object ProfileCache { suspended = existing?.suspended, suspensionReason = existing?.suspensionReason, deleted = existing?.deleted, - isClientPreviewOnly = true - ) + isClientPreviewOnly = true, + ), ) } @@ -145,42 +135,38 @@ object ProfileCache { suspended = existing?.suspended, suspensionReason = existing?.suspensionReason, deleted = existing?.deleted, - isClientPreviewOnly = true - ) + isClientPreviewOnly = true, + ), ) } - /** - * Load stored profiles into memory (merged: existing runtime entries win on id clash). - * Call after [ApiClient.loadPersistedData]. - */ + fun onActiveInstanceChanged(instanceId: String) { + ioScope.launch { + persistMutex.withLock { + loadedInstanceId = instanceId + profiles = if (instanceId.isNotEmpty()) { + runCatching { ProfileCacheStore.loadAllForInstance(instanceId) }.getOrDefault(emptyMap()) + } else { + emptyMap() + } + pruneUnusableClientPreviewsLocked() + } + } + } + suspend fun hydrateFromDisk() { + val instanceId = ru.fromchat.core.cache.CacheContext.activeInstanceId.value.trim() persistMutex.withLock { - val raw = settings.getString(SETTINGS_KEY, "").ifBlank { return } - runCatching { - val list = json.decodeFromString(ListSerializer(UserProfile.serializer()), raw) - val usable = list.filter { p -> - when { - !p.isClientPreviewOnly -> true - else -> - p.username.trim().isNotEmpty() || - !p.displayName.isNullOrBlank() - } - } - val fromDisk = usable.associateBy { it.id } - profiles = fromDisk + profiles - if (usable.size < list.size) { - schedulePersist() - } + loadedInstanceId = instanceId + profiles = if (instanceId.isNotEmpty()) { + runCatching { ProfileCacheStore.loadAllForInstance(instanceId) }.getOrDefault(emptyMap()) + } else { + emptyMap() } pruneUnusableClientPreviewsLocked() } } - /** - * Drops in-memory preview-only rows with no identity (e.g. bad runtime state after a failed merge). - * Call only while holding [persistMutex] or from [hydrateFromDisk] inside the lock. - */ private fun pruneUnusableClientPreviewsLocked() { val snap = profiles val toRemove = snap.filter { (_, p) -> @@ -194,26 +180,12 @@ object ProfileCache { cur = cur - id } profiles = cur - schedulePersist() } suspend fun clear() { persistMutex.withLock { profiles = emptyMap() - settings.putString(SETTINGS_KEY, "") - } - } - - private fun schedulePersist() { - ioScope.launch { - persistMutex.withLock { - val snap = profiles - val blob = json.encodeToString( - ListSerializer(UserProfile.serializer()), - snap.values.toList() - ) - settings.putString(SETTINGS_KEY, blob) - } + loadedInstanceId = "" } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt index c033510..dceb1cc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt @@ -2,6 +2,7 @@ package ru.fromchat.api import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.webSocket +import io.ktor.client.request.header import io.ktor.client.request.url import io.ktor.http.HttpMethod import io.ktor.websocket.Frame @@ -26,7 +27,9 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.AppForeground import ru.fromchat.core.Logger +import ru.fromchat.core.cache.CacheContext import ru.fromchat.core.config.Config +import ru.fromchat.core.instance.InstanceIdGuard import kotlin.concurrent.Volatile import kotlin.coroutines.coroutineContext import kotlin.coroutines.suspendCoroutine @@ -146,10 +149,14 @@ object WebSocketManager { connecting = true ConnectionStateStore.onConnecting() + val instanceId = CacheContext.activeInstanceId.value.trim() ApiClient.http.webSocket( method = HttpMethod.Get, request = { url(wsUrl) + if (instanceId.isNotEmpty()) { + header(InstanceIdGuard.INSTANCE_ID_HEADER, instanceId) + } } ) { reconnectDelayMs = MIN_RECONNECT_DELAY_MS diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ConversationIds.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ConversationIds.kt new file mode 100644 index 0000000..c96bd52 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ConversationIds.kt @@ -0,0 +1,18 @@ +package ru.fromchat.api.db + +/** General public group chat (only group for now). Groups use negative ids. */ +const val GENERAL_PUBLIC_GROUP_ID: Int = -1 + +fun conversationIdForGroup(groupId: Int): String = groupId.toString() + +fun conversationIdForDm(otherUserId: Int): String = "dm:$otherUserId" + +fun groupIdFromConversationId(conversationId: String): Int? = + conversationId.toIntOrNull()?.takeIf { it < 0 } + +fun dmOtherUserIdFromConversationId(conversationId: String): Int? = + if (conversationId.startsWith("dm:")) { + conversationId.removePrefix("dm:").toIntOrNull() + } else { + null + } 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 new file mode 100644 index 0000000..736e2e5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/DmStoredMessageContent.kt @@ -0,0 +1,173 @@ +package ru.fromchat.api.db + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import com.pr0gramm3r101.utils.files.PlatformFileSystem +import ru.fromchat.api.DmEnvelope +import ru.fromchat.api.Message +import ru.fromchat.ui.chat.AttachmentMediaLog +import ru.fromchat.ui.chat.DecryptedImageCache + +private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + +@Serializable +private data class PersistedDmMessagePayload( + @SerialName("text") val text: String, + @SerialName("envelope") val envelope: DmEnvelope, + @SerialName("fileThumbnails") val fileThumbnails: List? = null, + @SerialName("fileAspectRatios") val fileAspectRatios: List? = null, + @SerialName("fileSizes") val fileSizes: List? = null, + @SerialName("fileDimensions") val fileDimensions: List>? = null, + @SerialName("isContentCorrupted") val isContentCorrupted: Boolean = false, + /** Local decrypted preview file (survives outbox / upload staging cleanup). */ + @SerialName("localPreviewUri") val localPreviewUri: String? = null, +) + +data class ParsedDmMessageContent( + val text: String, + val envelope: DmEnvelope? = null, + val fileThumbnails: List? = null, + val fileAspectRatios: List? = null, + val fileSizes: List? = null, + val fileDimensions: List>? = null, + val isContentCorrupted: Boolean = false, + val localPreviewUri: String? = null, +) + +fun resolveLocalPreviewUri(message: Message): String? { + message.pendingFileUri?.takeIf { uri -> + DecryptedImageCache.isDecryptedImageCacheUri(uri) && localPreviewFileExists(uri) + }?.let { return it } + val cid = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() } + if (cid != null) { + DecryptedImageCache.getCached(message.id, fileIndex = 0, cid) + ?.takeIf { localPreviewFileExists(it) } + ?.let { return it } + } + if (message.id > 0) { + DecryptedImageCache.getCached(message.id, fileIndex = 0, clientMessageId = null) + ?.takeIf { localPreviewFileExists(it) } + ?.let { return it } + } + return null +} + +/** + * Layout width/height. Pixel pairs keep [w,h] order (landscape vs portrait). + * Do not min/max swap — that forces every landscape 4K image into portrait. + */ +internal fun attachmentDimensionsForLayout(w: Int, h: Int): Pair { + if (w <= 0 || h <= 0) return 1 to 1 + return w to h +} + +internal fun aspectRatioFromDimensionPair(w: Int, h: Int): Float { + val (dw, dh) = attachmentDimensionsForLayout(w, h) + return dw.toFloat() / dh.toFloat() +} + +private fun localPreviewFileExists(uri: String): Boolean { + val path = uri.removePrefix("file://") + return path.isNotEmpty() && PlatformFileSystem.exists(path) +} + +fun encodePersistedDmMessage(message: Message): String { + val envelope = message.dmEnvelope + ?: return message.content + if (message.files.isNullOrEmpty()) return message.content + val dims = message.fileDimensions?.map { listOf(it.first, it.second) } + val payload = PersistedDmMessagePayload( + text = message.content, + envelope = envelope, + fileThumbnails = message.fileThumbnails, + fileAspectRatios = message.fileAspectRatios, + fileSizes = message.fileSizes, + fileDimensions = dims, + isContentCorrupted = message.isContentCorrupted, + localPreviewUri = resolveLocalPreviewUri(message), + ) + AttachmentMediaLog.persist( + "encode", + "msgId" to message.id, + "clientId" to message.client_message_id, + "localPreview" to (payload.localPreviewUri?.take(64) ?: "null"), + "dims" to (dims?.firstOrNull()?.joinToString("x") ?: "null"), + ) + return json.encodeToString(payload) +} + +fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent { + val trimmed = plaintext.trim() + if (trimmed.startsWith("{")) { + val isPersistedEnvelope = runCatching { + json.parseToJsonElement(trimmed).jsonObject.containsKey("envelope") + }.getOrDefault(false) + if (isPersistedEnvelope) { + return runCatching { + val payload = json.decodeFromString(trimmed) + ParsedDmMessageContent( + text = payload.text, + envelope = payload.envelope, + fileThumbnails = payload.fileThumbnails, + fileAspectRatios = payload.fileAspectRatios, + fileSizes = payload.fileSizes, + fileDimensions = payload.fileDimensions?.mapNotNull { pair -> + if (pair.size >= 2) pair[0] to pair[1] else null + }, + isContentCorrupted = payload.isContentCorrupted, + localPreviewUri = payload.localPreviewUri?.takeIf { localPreviewFileExists(it) }, + ) + }.getOrElse { + ParsedDmMessageContent(text = plaintext) + } + } + return parseLegacyDmContentJson(trimmed) + } + return ParsedDmMessageContent(text = trimmed) +} + +private fun parseLegacyDmContentJson(plaintext: String): ParsedDmMessageContent { + if (!plaintext.startsWith("{")) { + return ParsedDmMessageContent(text = plaintext) + } + return runCatching { + val obj = json.parseToJsonElement(plaintext).jsonObject + val text = obj["text"]?.jsonPrimitive?.content ?: plaintext + val thumbArr = obj["fileThumbnails"]?.jsonArray ?: return@runCatching ParsedDmMessageContent(text) + val thumbnails = thumbArr.map { it.jsonPrimitive.content } + val arArr = obj["fileAspectRatios"]?.jsonArray + val parsed = arArr?.mapNotNull { elem -> + val a = elem as? JsonArray ?: return@mapNotNull null + if (a.size == 2) { + val w = (a.getOrNull(0) as? JsonPrimitive)?.content?.toIntOrNull() + val h = (a.getOrNull(1) as? JsonPrimitive)?.content?.toIntOrNull() + if (w != null && h != null && h > 0) { + Triple(w, h, aspectRatioFromDimensionPair(w, h)) + } else { + null + } + } else { + null + } + }?.takeIf { it.size == thumbnails.size } + val sizesArr = obj["fileSizes"]?.jsonArray + val fileSizes = sizesArr?.mapNotNull { (it as? JsonPrimitive)?.content?.toLongOrNull() } + ?.takeIf { it.size == thumbnails.size } + ParsedDmMessageContent( + text = text, + fileThumbnails = thumbnails.ifEmpty { null }, + fileAspectRatios = parsed?.map { it.third }, + fileSizes = fileSizes, + fileDimensions = parsed?.map { it.first to it.second }, + ) + }.getOrElse { + ParsedDmMessageContent(text = plaintext) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/InstanceRegistryStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/InstanceRegistryStore.kt new file mode 100644 index 0000000..d8236b7 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/InstanceRegistryStore.kt @@ -0,0 +1,111 @@ +package ru.fromchat.api.db + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.time.Clock +import ru.fromchat.core.ServerConfigData +import ru.fromchat.core.cache.CacheContext +import ru.fromchat.core.configKey +import ru.fromchat.api.outbox.cancelOutboxProcessing +import ru.fromchat.ui.chat.PublicChatPanelCache +import ru.fromchat.ui.dm.DmPanelCache + +object InstanceRegistryStore { + private val db get() = MessageDatabaseProvider.database + + private fun nowIso(): String = Clock.System.now().toString() + + suspend fun getActiveInstanceIdForConfig(config: ServerConfigData): String? = + withContext(Dispatchers.Default) { + MessageDatabaseProvider.withDatabaseRecover { + db.messageDatabaseQueries + .selectActiveInstanceIdForConfig(config.configKey()) + .executeAsOneOrNull() + } + } + + suspend fun registerInstanceEncountered(instanceId: String) { + val id = instanceId.trim() + if (id.isEmpty()) return + val now = nowIso() + withContext(Dispatchers.Default) { + MessageDatabaseProvider.withDatabaseRecover { + val existing = db.messageDatabaseQueries + .selectAllInstanceIds() + .executeAsList() + .any { it.equals(id, ignoreCase = true) } + if (existing) { + db.messageDatabaseQueries.touchInstanceRegistry(now, id) + } else { + db.messageDatabaseQueries.upsertInstanceRegistry(id, now, now) + } + } + } + } + + suspend fun rebindServerInstance(config: ServerConfigData, newInstanceId: String) { + val newId = newInstanceId.trim() + require(newId.isNotEmpty()) + val previousActive = CacheContext.activeInstanceId.value.trim() + if (previousActive.isNotEmpty() && !previousActive.equals(newId, ignoreCase = true)) { + cancelOutboxProcessing(previousActive) + } + val key = config.configKey() + val now = nowIso() + withContext(Dispatchers.Default) { + MessageDatabaseProvider.withDatabaseRecover { + db.messageDatabaseQueries.upsertServerBinding(key, newId, now) + val existing = db.messageDatabaseQueries + .selectAllInstanceIds() + .executeAsList() + .any { it.equals(newId, ignoreCase = true) } + if (existing) { + db.messageDatabaseQueries.touchInstanceRegistry(now, newId) + } else { + db.messageDatabaseQueries.upsertInstanceRegistry(newId, now, now) + } + } + } + val userId = CacheContext.activeUserId.value + CacheContext.setActiveInstance(newId, userId) + PublicChatPanelCache.clear() + DmPanelCache.clearAll() + } + + suspend fun rebindServerInstanceOnMismatch( + config: ServerConfigData, + previousId: String?, + fetchedId: String, + ) { + val prev = previousId?.trim().orEmpty() + val next = fetchedId.trim() + if (prev.isEmpty() || prev.equals(next, ignoreCase = true)) { + rebindServerInstance(config, next) + return + } + rebindServerInstance(config, next) + } + + /** User-initiated clear for one instance partition. */ + suspend fun purgePartition(instanceId: String) { + val id = instanceId.trim() + if (id.isEmpty()) return + withContext(Dispatchers.Default) { + MessageDatabaseProvider.withDatabaseRecover { + db.messageDatabaseQueries.deleteAllMessagesForInstance(id) + db.messageDatabaseQueries.deleteAllConversationsForInstance(id) + db.messageDatabaseQueries.deleteAllOutboxForInstance(id) + db.messageDatabaseQueries.deleteAllAttachmentsForInstance(id) + db.messageDatabaseQueries.deleteAllProfilesForInstance(id) + } + } + } + + suspend fun purgeAllCache() { + withContext(Dispatchers.Default) { + MessageDatabaseProvider.withDatabaseRecover { + db.messageDatabaseQueries.purgeAllCache() + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/LocalCacheWipe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/LocalCacheWipe.kt new file mode 100644 index 0000000..b4f6df5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/LocalCacheWipe.kt @@ -0,0 +1,26 @@ +package ru.fromchat.api.db + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import ru.fromchat.api.outbox.cancelOutboxProcessing +import ru.fromchat.core.cache.CacheContext +import ru.fromchat.core.cache.wipeFromChatCacheDirectory +import ru.fromchat.ui.chat.PublicChatPanelCache +import ru.fromchat.ui.dm.DmPanelCache + +/** + * Drops the on-disk FromChat cache tree and reopens SQLite on next access. + * Call [writeFromChatCacheGeneration] after this when wiping from settings. + */ +suspend fun wipeLocalCacheOnDisk() { + val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("") + if (instanceId.isNotEmpty()) { + cancelOutboxProcessing(instanceId) + } + withContext(Dispatchers.Default) { + MessageDatabaseProvider.closeAndReset() + } + wipeFromChatCacheDirectory() + PublicChatPanelCache.clear() + DmPanelCache.clearAll() +} 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 67ed736..684ac0c 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 @@ -1,21 +1,28 @@ package ru.fromchat.api.db +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import ru.fromchat.api.ApiClient 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.dedupeMessagesByClientId +import ru.fromchat.ui.chat.dropSupersededOptimisticMessages import ru.fromchat.api.ProfileCache +import ru.fromchat.api.outbox.DmAttachmentOutboxPayload +import ru.fromchat.api.outbox.OutgoingMessageCoordinator +import ru.fromchat.core.cache.CacheContext +import ru.fromchat.core.cache.CacheValidator import ru.fromchat.db.Conversation import ru.fromchat.db.MessageDatabase import ru.fromchat.db.Message as DbMessage -/** - * Simple repository wrapping [MessageDatabase] for caching messages and conversations. - * - * This is intentionally minimal – it focuses on the flows the app needs for - * public chat and DMs rather than trying to mirror the entire backend schema. - */ data class CachedConversation( val id: String, val otherUserId: Int, @@ -26,16 +33,11 @@ data class CachedConversation( object MessageCacheStore { private val db: MessageDatabase get() = MessageDatabaseProvider.database + private val outboxJson = Json { ignoreUnknownKeys = true; encodeDefaults = true } - private fun conversationIdForPublic(): String = "public" - private fun conversationIdForDm(otherUserId: Int): String = "dm:$otherUserId" + private fun instanceId(): String = CacheContext.requireActiveInstanceId() - private fun otherUserIdFromDmConversationId(conversationId: String): Int? = - if (conversationId.startsWith("dm:")) { - conversationId.removePrefix("dm:").toIntOrNull() - } else { - null - } + private fun conversationIdForPublic(): String = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID) private fun truncateDmListPreview(text: String, maxLen: Int = 120): String { val t = text.trim() @@ -43,73 +45,48 @@ object MessageCacheStore { return if (t.length > maxLen) t.take(maxLen) + "\u2026" else t } - /** Sets [Conversation.lastMessagePreview] from the latest cached plaintext row (DMs are encrypted on the API). */ - private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) { - val convId = conversationIdForDm(otherUserId) - withContext(Dispatchers.Default) { - val row = db.messageDatabaseQueries.selectConversations().executeAsList() - .find { it.id == convId } ?: return@withContext - val recent = db.messageDatabaseQueries - .selectRecentMessagesByConversation(convId, 1) - .executeAsList() - .firstOrNull() - val rawPreview = recent?.content?.orEmpty()?.trim().orEmpty() - val preview = rawPreview.takeIf { it.isNotEmpty() } - ?.let { truncateDmListPreview(it) } - ?.takeIf { it.isNotEmpty() } - db.messageDatabaseQueries.upsertConversation( - id = row.id, - type = row.type, - otherUserId = row.otherUserId, - displayName = row.displayName, - lastMessageId = row.lastMessageId, - lastMessagePreview = preview, - unreadCount = row.unreadCount, - updatedAt = row.updatedAt - ) - } - } + fun observeMessages(instanceId: String, conversationId: String): Flow> = + db.messageDatabaseQueries + .selectMessagesByConversation(instanceId, conversationId) + .asFlow() + .mapToList(Dispatchers.Default) + .map { rows -> + val raw = rows.map { it.toAppMessage() } + val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) + sortMessagesForChatDisplay( + validatedOrEmpty( + conversationId, + dedupeMessagesByClientId( + enrichQueuedOutboundUi(withoutSuperseded, conversationId), + ), + ), + ) + } - /** - * Full history for a conversation (unbounded). Prefer [loadRecentPublicMessages] when opening UI. - */ - suspend fun loadPublicMessages(): List { - return loadMessages(conversationIdForPublic()) - } + suspend fun loadPublicMessages(): List = + loadMessages(conversationIdForPublic()) - /** - * Most recent [limit] messages for public chat, chronological (oldest → newest). - * Avoids reading the entire `"public"` thread from SQLite when the cache has grown large. - */ - suspend fun loadRecentPublicMessages(limit: Long): List { - return loadRecentMessages(conversationIdForPublic(), limit) - } + suspend fun loadRecentPublicMessages(limit: Long): List = + loadRecentMessages(conversationIdForPublic(), limit) suspend fun replacePublicMessages(messages: List) { - val pending = loadPendingMessages(conversationIdForPublic()) + val convId = conversationIdForPublic() + val pending = loadPendingMessages(convId) val stillPending = pending.filter { p -> val cid = p.client_message_id cid == null || messages.none { it.client_message_id == cid } } - val merged = (messages + stillPending) - .distinctBy { msg -> - when { - msg.id > 0 -> "i:${msg.id}" - msg.client_message_id != null -> "c:${msg.client_message_id}" - else -> "i:${msg.id}" - } - } - .sortedBy { it.timestamp } - replaceMessages(conversationIdForPublic(), merged) + val merged = dedupeMessagesByClientId(messages + stillPending) + .let { sortMessagesForChatDisplay(it) } + replaceMessages(convId, merged) } suspend fun clearPublicMessages() { clearConversationMessages(conversationIdForPublic()) } - suspend fun loadDmMessages(otherUserId: Int): List { - return loadMessages(conversationIdForDm(otherUserId)) - } + suspend fun loadDmMessages(otherUserId: Int): List = + loadMessages(conversationIdForDm(otherUserId)) suspend fun clearDmMessages(otherUserId: Int) { clearConversationMessages(conversationIdForDm(otherUserId)) @@ -118,22 +95,50 @@ object MessageCacheStore { suspend fun replaceDmMessages(otherUserId: Int, messages: List) { val convId = conversationIdForDm(otherUserId) val pending = loadPendingMessages(convId) - val stillPending = pending.filter { p -> - val cid = p.client_message_id - cid == null || messages.none { it.client_message_id == cid } + val stillPending = filterStillPendingForReplace(convId, pending, messages) + val before = messages + stillPending + val merged = dedupeMessagesByClientId( + dropSupersededOptimisticMessages(before, ApiClient.user?.id), + ).let { sortMessagesForChatDisplay(it) } + val iid = instanceId() + withContext(Dispatchers.Default) { + purgeSupersededPendingRows(iid, convId, before, merged) } - val merged = (messages + stillPending) - .distinctBy { msg -> - when { - msg.id > 0 -> "i:${msg.id}" - msg.client_message_id != null -> "c:${msg.client_message_id}" - else -> "i:${msg.id}" - } - } - .sortedBy { it.timestamp } replaceMessages(convId, merged) } + private suspend fun filterStillPendingForReplace( + conversationId: String, + pending: List, + messages: List, + ): List { + val selfId = ApiClient.user?.id + val selfHasConfirmedAttachment = messages.any { msg -> + msg.id > 0 && msg.user_id == selfId && !msg.files.isNullOrEmpty() + } + val loneSelfPending = pending.count { it.id < 0 && it.user_id == selfId } == 1 + return pending.filter { p -> + val cid = p.client_message_id?.trim().orEmpty() + if (cid.isNotEmpty()) { + if (hasSentMessageWithClientId(conversationId, cid)) return@filter false + if (messages.any { it.id > 0 && it.client_message_id == cid }) return@filter false + } + val ghostTextOnly = p.id < 0 && + p.files.isNullOrEmpty() && + p.pendingFileUri.isNullOrBlank() && + p.uploadJobId.isNullOrBlank() + if ( + ghostTextOnly && + selfHasConfirmedAttachment && + loneSelfPending && + p.user_id == selfId + ) { + return@filter false + } + cid.isEmpty() || messages.none { it.client_message_id == cid } + } + } + suspend fun upsertPublicMessage(message: Message) { upsertSingle(conversationIdForPublic(), message) } @@ -151,6 +156,14 @@ object MessageCacheStore { deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId) } + suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) { + deleteMessageById(conversationIdForDm(otherUserId), messageId) + } + + suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) { + deleteByClientMessageId(conversationId, clientMessageId) + } + suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) { confirmMessage(conversationIdForPublic(), clientMessageId, confirmed) } @@ -159,138 +172,43 @@ object MessageCacheStore { confirmMessage(conversationIdForDm(otherUserId), clientMessageId, confirmed) } - private suspend fun clearConversationMessages(conversationId: String) { - withContext(Dispatchers.Default) { - db.messageDatabaseQueries.deleteMessagesForConversation(conversationId) - } - } - - private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) { - withContext(Dispatchers.Default) { - db.messageDatabaseQueries.deleteMessageByClientMessageId(conversationId, clientMessageId) - } - } - - private suspend fun upsertSingle(conversationId: String, msg: Message) { + /** After decrypt, persist [localPreviewUri] so reopen skips network. */ + suspend fun patchDmMessageLocalPreview( + otherUserId: Int, + messageId: Int, + localPreviewUri: String, + ) { + if (messageId <= 0 || !DecryptedImageCache.isDecryptedImageCacheUri(localPreviewUri)) return + val convId = conversationIdForDm(otherUserId) + val iid = instanceId() withContext(Dispatchers.Default) { + val row = db.messageDatabaseQueries + .selectMessageById(iid, convId, messageId.toLong()) + .executeAsOneOrNull() ?: return@withContext + val msg = row.toAppMessage().copy(pendingFileUri = localPreviewUri) + if (msg.files.isNullOrEmpty() || msg.dmEnvelope == null) return@withContext db.messageDatabaseQueries.upsertMessage( + instanceId = iid, id = msg.id.toLong(), - conversationId = conversationId, + conversationId = convId, userId = msg.user_id.toLong(), - content = msg.content, + content = encodePersistedDmMessage(msg), timestamp = msg.timestamp, isRead = if (msg.is_read) 1L else 0L, isEdited = if (msg.is_edited) 1L else 0L, replyToId = msg.reply_to?.id?.toLong(), clientMessageId = msg.client_message_id, - deletedFlag = 0L + deletedFlag = 0L, + sendStatus = "sent", ) } } - private suspend fun confirmMessage(conversationId: String, clientMessageId: String, confirmed: Message) { - withContext(Dispatchers.Default) { - db.messageDatabaseQueries.transaction { - db.messageDatabaseQueries.deleteMessageByClientMessageId(conversationId, clientMessageId) - db.messageDatabaseQueries.upsertMessage( - id = confirmed.id.toLong(), - conversationId = conversationId, - userId = confirmed.user_id.toLong(), - content = confirmed.content, - timestamp = confirmed.timestamp, - isRead = if (confirmed.is_read) 1L else 0L, - isEdited = if (confirmed.is_edited) 1L else 0L, - replyToId = confirmed.reply_to?.id?.toLong(), - clientMessageId = confirmed.client_message_id, - deletedFlag = 0L - ) - } - } - otherUserIdFromDmConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) } - } - - private suspend fun loadMessages(conversationId: String): List = - withContext(Dispatchers.Default) { - db.messageDatabaseQueries - .selectMessagesByConversation(conversationId) - .executeAsList() - .map { row: DbMessage -> row.toAppMessage() } - } - - private suspend fun loadRecentMessages(conversationId: String, limit: Long): List = - withContext(Dispatchers.Default) { - db.messageDatabaseQueries - .selectRecentMessagesByConversation(conversationId, limit) - .executeAsList() - .map { row: DbMessage -> row.toAppMessage() } - .reversed() - } - - private suspend fun loadPendingMessages(conversationId: String): List = - withContext(Dispatchers.Default) { - db.messageDatabaseQueries - .selectPendingMessagesByConversation(conversationId) - .executeAsList() - .map { row: DbMessage -> row.toAppMessage() } - } - - private fun DbMessage.toAppMessage(): Message { - val uid = userId.toInt() - val self = ApiClient.user - val profile = ProfileCache.get(uid) - val usernameResolved = when { - self != null && uid == self.id -> self.username - else -> profile?.username?.takeIf { it.isNotBlank() } - ?: profile?.displayName?.takeIf { it.isNotBlank() } - ?: "" - } - val pictureResolved = when { - self != null && uid == self.id -> self.profile_picture - else -> profile?.profilePicture?.takeIf { it.isNotBlank() } - } - return Message( - id = id.toInt(), - user_id = uid, - content = content, - timestamp = timestamp, - is_read = isRead != 0L, - is_edited = isEdited != 0L, - username = usernameResolved, - profile_picture = pictureResolved, - verified = profile?.verified, - reply_to = null, - client_message_id = clientMessageId, - reactions = null, - files = null - ) - } - - private suspend fun replaceMessages(conversationId: String, messages: List) { - withContext(Dispatchers.Default) { - db.messageDatabaseQueries.transaction { - db.messageDatabaseQueries.deleteMessagesForConversation(conversationId) - messages.forEach { msg -> - db.messageDatabaseQueries.upsertMessage( - id = msg.id.toLong(), - conversationId = conversationId, - userId = msg.user_id.toLong(), - content = msg.content, - timestamp = msg.timestamp, - isRead = if (msg.is_read) 1L else 0L, - isEdited = if (msg.is_edited) 1L else 0L, - replyToId = msg.reply_to?.id?.toLong(), - clientMessageId = msg.client_message_id, - deletedFlag = 0L - ) - } - } - } - otherUserIdFromDmConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) } - } - suspend fun markMessageDeleted(conversationId: String, messageId: Int) { + val iid = instanceId() withContext(Dispatchers.Default) { db.messageDatabaseQueries.markMessageDeleted( + instanceId = iid, id = messageId.toLong(), conversationId = conversationId ) @@ -298,6 +216,7 @@ object MessageCacheStore { } suspend fun replaceDmConversations(conversations: List) { + val iid = instanceId() withContext(Dispatchers.Default) { db.messageDatabaseQueries.transaction { conversations.forEach { conv -> @@ -305,7 +224,7 @@ object MessageCacheStore { val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: conv.user.username.trim() val recent = db.messageDatabaseQueries - .selectRecentMessagesByConversation(conversationId, 1) + .selectRecentMessagesByConversation(iid, conversationId, 1) .executeAsList() .firstOrNull() val rawPreview = recent?.content?.orEmpty()?.trim().orEmpty() @@ -313,6 +232,7 @@ object MessageCacheStore { ?.let { truncateDmListPreview(it) } ?.takeIf { it.isNotEmpty() } db.messageDatabaseQueries.upsertConversation( + instanceId = iid, id = conversationId, type = "dm", otherUserId = conv.user.id.toLong(), @@ -330,7 +250,7 @@ object MessageCacheStore { suspend fun loadCachedDmConversations(): List = withContext(Dispatchers.Default) { db.messageDatabaseQueries - .selectConversations() + .selectConversationsForInstance(instanceId()) .executeAsList() .filter { row: Conversation -> row.type == "dm" } .map { row: Conversation -> @@ -343,5 +263,343 @@ object MessageCacheStore { ) } } -} + private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) { + val iid = instanceId() + val convId = conversationIdForDm(otherUserId) + withContext(Dispatchers.Default) { + val row = db.messageDatabaseQueries + .selectConversationsForInstance(iid) + .executeAsList() + .find { it.id == convId } ?: return@withContext + val recent = db.messageDatabaseQueries + .selectRecentMessagesByConversation(iid, convId, 1) + .executeAsList() + .firstOrNull() + val rawPreview = recent?.content?.orEmpty()?.trim().orEmpty() + val preview = rawPreview.takeIf { it.isNotEmpty() } + ?.let { truncateDmListPreview(it) } + ?.takeIf { it.isNotEmpty() } + db.messageDatabaseQueries.upsertConversation( + instanceId = iid, + id = row.id, + type = row.type, + otherUserId = row.otherUserId, + displayName = row.displayName, + lastMessageId = row.lastMessageId, + lastMessagePreview = preview, + unreadCount = row.unreadCount, + updatedAt = row.updatedAt + ) + } + } + + private suspend fun clearConversationMessages(conversationId: String) { + val iid = instanceId() + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId) + } + } + + private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) { + val iid = instanceId() + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId) + } + } + + private suspend fun deleteMessageById(conversationId: String, messageId: Int) { + val iid = instanceId() + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.deleteMessageById( + instanceId = iid, + conversationId = conversationId, + id = messageId.toLong(), + ) + } + } + + private suspend fun upsertSingle(conversationId: String, msg: Message) { + val iid = instanceId() + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.upsertMessage( + instanceId = iid, + id = msg.id.toLong(), + conversationId = conversationId, + userId = msg.user_id.toLong(), + content = msg.content, + timestamp = msg.timestamp, + isRead = if (msg.is_read) 1L else 0L, + isEdited = if (msg.is_edited) 1L else 0L, + replyToId = msg.reply_to?.id?.toLong(), + clientMessageId = msg.client_message_id, + deletedFlag = 0L, + sendStatus = if (msg.id < 0) "pending" else "sent" + ) + } + } + + private suspend fun confirmMessage(conversationId: String, clientMessageId: String, confirmed: Message) { + val iid = instanceId() + val storedContent = encodePersistedDmMessage(confirmed) + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.transaction { + db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId) + db.messageDatabaseQueries.upsertMessage( + instanceId = iid, + id = confirmed.id.toLong(), + conversationId = conversationId, + userId = confirmed.user_id.toLong(), + content = storedContent, + timestamp = confirmed.timestamp, + isRead = if (confirmed.is_read) 1L else 0L, + isEdited = if (confirmed.is_edited) 1L else 0L, + replyToId = confirmed.reply_to?.id?.toLong(), + clientMessageId = confirmed.client_message_id, + deletedFlag = 0L, + sendStatus = "sent" + ) + } + } + dmOtherUserIdFromConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) } + } + + private suspend fun loadMessages(conversationId: String): List { + val iid = instanceId() + OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(iid) + return withContext(Dispatchers.Default) { + val raw = db.messageDatabaseQueries + .selectMessagesByConversation(iid, conversationId) + .executeAsList() + .map { row: DbMessage -> row.toAppMessage() } + val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) + purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded) + sortMessagesForChatDisplay( + validatedOrEmpty( + conversationId, + dedupeMessagesByClientId( + enrichQueuedOutboundUi(withoutSuperseded, conversationId), + ), + ), + ) + } + } + + private suspend fun loadRecentMessages(conversationId: String, limit: Long): List { + val iid = instanceId() + return withContext(Dispatchers.Default) { + db.messageDatabaseQueries + .selectRecentMessagesByConversation(iid, conversationId, limit) + .executeAsList() + .map { row: DbMessage -> row.toAppMessage() } + .reversed() + } + } + + private suspend fun loadPendingMessages(conversationId: String): List { + val iid = instanceId() + return withContext(Dispatchers.Default) { + db.messageDatabaseQueries + .selectPendingMessagesByConversation(iid, conversationId) + .executeAsList() + .map { row: DbMessage -> row.toAppMessage() } + .filter { row -> + val cid = row.client_message_id?.trim().orEmpty() + cid.isEmpty() || + db.messageDatabaseQueries + .selectSentMessageIdByClientMessageId(iid, conversationId, cid) + .executeAsOneOrNull() == null + } + .let { enrichQueuedOutboundUi(it, conversationId) } + .let { dedupeMessagesByClientId(it) } + .let { sortMessagesForChatDisplay(it) } + } + } + + private fun enrichQueuedOutboundUi( + messages: List, + conversationId: String, + ): List { + if (messages.none { it.id < 0 }) return messages + val iid = instanceId() + val attachmentOutbox = db.messageDatabaseQueries + .selectPendingOutboxForInstance(iid) + .executeAsList() + .filter { + it.conversationId == conversationId && + ( + it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT || + it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK + ) + } + if (attachmentOutbox.isEmpty()) return messages + val confirmedClientIds = messages + .filter { it.id > 0 } + .mapNotNull { it.client_message_id?.trim()?.takeIf { cid -> cid.isNotEmpty() } } + .toSet() + val payloads = attachmentOutbox.associate { row -> + row.clientMessageId to runCatching { + Triple( + outboxJson.decodeFromString(row.payloadJson), + row.bytesUploaded, + row.kind, + ) + }.getOrNull() + } + return messages.mapNotNull { msg -> + if (msg.id >= 0) return@mapNotNull msg + val cid = msg.client_message_id?.trim().orEmpty() + if (cid.isNotEmpty() && cid in confirmedClientIds) return@mapNotNull null + if (cid.isEmpty()) return@mapNotNull msg + val entry = payloads[cid] ?: return@mapNotNull msg + val (payload, bytesUploaded, kind) = entry + val uploadFinished = kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK + val totalBytes = when { + payload.encryptedFileSizeBytes > 0L -> payload.encryptedFileSizeBytes + else -> payload.fileSizeBytes + } + val percent = when { + uploadFinished -> null + totalBytes > 0L && bytesUploaded > 0L -> + ((bytesUploaded.toDouble() / totalBytes.toDouble()) * 100.0).toInt().coerceIn(0, 99) + bytesUploaded > 0L -> 1 + else -> msg.uploadProgress ?: 0 + } + msg.copy( + pendingFileUri = payload.fileUri, + pendingFilename = payload.filename, + pendingFileAspectRatio = payload.aspectRatio?.takeIf { it > 0f } + ?: msg.pendingFileAspectRatio, + uploadJobId = cid, + uploadProgress = percent, + ) + } + } + + private fun purgeSupersededPendingRows( + instanceId: String, + conversationId: String, + before: List, + after: List, + ) { + val keptIds = after.map { it.id }.toSet() + before.filter { it.id < 0 && it.id !in keptIds }.forEach { dropped -> + val cid = dropped.client_message_id?.trim().orEmpty() + if (cid.isNotEmpty()) { + db.messageDatabaseQueries.deletePendingMessageByClientMessageId( + instanceId, + conversationId, + cid, + ) + db.messageDatabaseQueries.deleteOutboxItem(instanceId, cid) + } + } + } + + private fun DbMessage.toAppMessage(): Message { + val uid = userId.toInt() + val self = ApiClient.user + val profile = ProfileCache.get(uid) + val usernameResolved = when { + self != null && uid == self.id -> self.username + else -> profile?.username?.takeIf { it.isNotBlank() } + ?: profile?.displayName?.takeIf { it.isNotBlank() } + ?: "" + } + val pictureResolved = when { + self != null && uid == self.id -> self.profile_picture + else -> profile?.profilePicture?.takeIf { it.isNotBlank() } + } + val parsed = parseDmMessageContent(content) + val base = Message( + id = id.toInt(), + user_id = uid, + content = parsed.text, + timestamp = timestamp, + is_read = isRead != 0L, + is_edited = isEdited != 0L, + username = usernameResolved, + profile_picture = pictureResolved, + verified = profile?.verified, + reply_to = null, + client_message_id = clientMessageId, + reactions = null, + files = parsed.envelope?.files, + dmEnvelope = parsed.envelope, + fileThumbnails = parsed.fileThumbnails, + fileAspectRatios = parsed.fileAspectRatios, + fileSizes = parsed.fileSizes, + fileDimensions = parsed.fileDimensions, + isContentCorrupted = parsed.isContentCorrupted, + ) + return base.copy( + pendingFileUri = parsed.localPreviewUri ?: resolveLocalPreviewUri(base), + pendingFileAspectRatio = parsed.fileAspectRatios?.firstOrNull() + ?: parsed.fileDimensions?.firstOrNull()?.let { (w, h) -> + aspectRatioFromDimensionPair(w, h) + }, + ) + } + + suspend fun clearAll() { + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.purgeAllCache() + } + } + + private fun validatedOrEmpty(conversationId: String, messages: List): List { + val self = ApiClient.user?.id + if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) { + return emptyList() + } + return CacheValidator.filterMessages(conversationId, messages, self) + } + + private suspend fun replaceMessages(conversationId: String, messages: List) { + val self = ApiClient.user?.id + if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) { + clearConversationMessages(conversationId) + return + } + val validated = CacheValidator.filterMessages(conversationId, messages, self) + val iid = instanceId() + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.transaction { + db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId) + validated.forEach { msg -> + db.messageDatabaseQueries.upsertMessage( + instanceId = iid, + id = msg.id.toLong(), + conversationId = conversationId, + userId = msg.user_id.toLong(), + content = when { + msg.id < 0 -> msg.content + !msg.files.isNullOrEmpty() && msg.dmEnvelope != null -> + encodePersistedDmMessage(msg) + else -> msg.content + }, + timestamp = msg.timestamp, + isRead = if (msg.is_read) 1L else 0L, + isEdited = if (msg.is_edited) 1L else 0L, + replyToId = msg.reply_to?.id?.toLong(), + clientMessageId = msg.client_message_id, + deletedFlag = 0L, + sendStatus = if (msg.id < 0) "pending" else "sent" + ) + } + } + } + dmOtherUserIdFromConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) } + } + + suspend fun hasSentMessageWithClientId(conversationId: String, clientMessageId: String): Boolean { + val cid = clientMessageId.trim() + if (cid.isEmpty()) return false + val iid = instanceId() + return withContext(Dispatchers.Default) { + db.messageDatabaseQueries + .selectSentMessageIdByClientMessageId(iid, conversationId, cid) + .executeAsOneOrNull() != null + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseExpectedSchema.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseExpectedSchema.kt new file mode 100644 index 0000000..403f523 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseExpectedSchema.kt @@ -0,0 +1,180 @@ +package ru.fromchat.api.db + +/** + * Target SQLDelight schema (source of truth for diff). Not a version number — only shape. + */ +internal object MessageDatabaseExpectedSchema { + val managedTables: Set = setOf( + "server_binding", + "instance_registry", + "conversation", + "message", + "attachment", + "outbox", + "profile_cache", + ) + + val tableCreateSql: Map = mapOf( + "server_binding" to """ + CREATE TABLE server_binding ( + configKey TEXT NOT NULL PRIMARY KEY, + activeInstanceId TEXT NOT NULL, + updatedAt TEXT + ) + """.trimIndent(), + "instance_registry" to """ + CREATE TABLE instance_registry ( + instanceId TEXT NOT NULL PRIMARY KEY, + firstSeenAt TEXT NOT NULL, + lastSeenAt TEXT NOT NULL + ) + """.trimIndent(), + "conversation" to """ + CREATE TABLE conversation ( + instanceId TEXT NOT NULL, + id TEXT NOT NULL, + type TEXT NOT NULL, + otherUserId INTEGER, + displayName TEXT, + lastMessageId INTEGER, + lastMessagePreview TEXT, + unreadCount INTEGER NOT NULL DEFAULT 0, + updatedAt TEXT, + PRIMARY KEY (instanceId, id) + ) + """.trimIndent(), + "message" to """ + CREATE TABLE message ( + instanceId TEXT NOT NULL, + id INTEGER NOT NULL, + conversationId TEXT NOT NULL, + userId INTEGER NOT NULL, + content TEXT NOT NULL, + timestamp TEXT NOT NULL, + isRead INTEGER NOT NULL, + isEdited INTEGER NOT NULL, + replyToId INTEGER, + clientMessageId TEXT, + deletedFlag INTEGER NOT NULL DEFAULT 0, + sendStatus TEXT, + PRIMARY KEY (instanceId, conversationId, id) + ) + """.trimIndent(), + "attachment" to """ + CREATE TABLE attachment ( + instanceId TEXT NOT NULL, + id INTEGER NOT NULL, + messageId INTEGER NOT NULL, + conversationId TEXT NOT NULL, + remotePath TEXT, + localPath TEXT, + status TEXT NOT NULL, + blurhash TEXT, + aspectRatio REAL, + size INTEGER, + bytesTransferred INTEGER NOT NULL DEFAULT 0, + clientMessageId TEXT, + PRIMARY KEY (instanceId, id, messageId, conversationId) + ) + """.trimIndent(), + "outbox" to """ + CREATE TABLE outbox ( + instanceId TEXT NOT NULL, + clientMessageId TEXT NOT NULL, + conversationId TEXT NOT NULL, + kind TEXT NOT NULL, + payloadJson TEXT NOT NULL, + retryCount INTEGER NOT NULL DEFAULT 0, + nextAttemptAt TEXT, + bytesUploaded INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (instanceId, clientMessageId) + ) + """.trimIndent(), + "profile_cache" to """ + CREATE TABLE profile_cache ( + instanceId TEXT NOT NULL, + userId INTEGER NOT NULL, + json TEXT NOT NULL, + PRIMARY KEY (instanceId, userId) + ) + """.trimIndent(), + ) + + /** Columns that must exist; value is ALTER TABLE suffix when addable without rebuild. */ + val requiredColumns: Map> = mapOf( + "server_binding" to mapOf( + "configKey" to "TEXT NOT NULL", + "activeInstanceId" to "TEXT NOT NULL", + "updatedAt" to "TEXT", + ), + "instance_registry" to mapOf( + "instanceId" to "TEXT NOT NULL", + "firstSeenAt" to "TEXT NOT NULL", + "lastSeenAt" to "TEXT NOT NULL", + ), + "conversation" to mapOf( + "instanceId" to "TEXT NOT NULL", + "id" to "TEXT NOT NULL", + "type" to "TEXT NOT NULL", + "otherUserId" to "INTEGER", + "displayName" to "TEXT", + "lastMessageId" to "INTEGER", + "lastMessagePreview" to "TEXT", + "unreadCount" to "INTEGER NOT NULL DEFAULT 0", + "updatedAt" to "TEXT", + ), + "message" to mapOf( + "instanceId" to "TEXT NOT NULL", + "id" to "INTEGER NOT NULL", + "conversationId" to "TEXT NOT NULL", + "userId" to "INTEGER NOT NULL", + "content" to "TEXT NOT NULL", + "timestamp" to "TEXT NOT NULL", + "isRead" to "INTEGER NOT NULL", + "isEdited" to "INTEGER NOT NULL", + "replyToId" to "INTEGER", + "clientMessageId" to "TEXT", + "deletedFlag" to "INTEGER NOT NULL DEFAULT 0", + "sendStatus" to "TEXT", + ), + "attachment" to mapOf( + "instanceId" to "TEXT NOT NULL", + "id" to "INTEGER NOT NULL", + "messageId" to "INTEGER NOT NULL", + "conversationId" to "TEXT NOT NULL", + "remotePath" to "TEXT", + "localPath" to "TEXT", + "status" to "TEXT NOT NULL", + "blurhash" to "TEXT", + "aspectRatio" to "REAL", + "size" to "INTEGER", + "bytesTransferred" to "INTEGER NOT NULL DEFAULT 0", + "clientMessageId" to "TEXT", + ), + "outbox" to mapOf( + "instanceId" to "TEXT NOT NULL", + "clientMessageId" to "TEXT NOT NULL", + "conversationId" to "TEXT NOT NULL", + "kind" to "TEXT NOT NULL", + "payloadJson" to "TEXT NOT NULL", + "retryCount" to "INTEGER NOT NULL DEFAULT 0", + "nextAttemptAt" to "TEXT", + "bytesUploaded" to "INTEGER NOT NULL DEFAULT 0", + ), + "profile_cache" to mapOf( + "instanceId" to "TEXT NOT NULL", + "userId" to "INTEGER NOT NULL", + "json" to "TEXT NOT NULL", + ), + ) + + /** Tables that require [instanceId]; missing it triggers rebuild-with-copy, not wipe. */ + val partitionedTables: Set = setOf("message", "conversation", "attachment") + + val requiredIndexes: Map = mapOf( + "message_instance_conversation_index" to + "CREATE INDEX IF NOT EXISTS message_instance_conversation_index ON message(instanceId, conversationId, timestamp)", + ) + + val obsoleteIndexes: Set = setOf("message_conversation_index") +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.kt new file mode 100644 index 0000000..f3873fc --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.kt @@ -0,0 +1,3 @@ +package ru.fromchat.api.db + +internal expect fun withMessageDatabaseLock(block: () -> T): T diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseProvider.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseProvider.kt index c272234..57c3a4e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseProvider.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseProvider.kt @@ -9,8 +9,62 @@ import ru.fromchat.db.MessageDatabase expect fun provideMessageDatabaseDriver(): SqlDriver object MessageDatabaseProvider { - val database: MessageDatabase by lazy { - MessageDatabase(provideMessageDatabaseDriver()) + private var driver: SqlDriver? = null + private var databaseHolder: MessageDatabase? = null + + val database: MessageDatabase + get() = withMessageDatabaseLock { + databaseHolder ?: MessageDatabase(openDriverLocked()).also { databaseHolder = it } + } + + /** + * Closes the open SQLite connection. Required before deleting `cacheDir/fromchat/` + * (otherwise the next write hits SQLITE_READONLY_DBMOVED). + */ + fun closeAndReset() { + withMessageDatabaseLock { + databaseHolder = null + runCatching { driver?.close() } + driver = null + } + } + + /** Runs [block] once; on a stale connection after cache wipe, resets and retries. */ + internal fun withDatabaseRecover(block: () -> T): T { + return try { + block() + } catch (e: Exception) { + if (!isStaleSqliteConnection(e)) throw e + closeAndReset() + block() + } + } + + internal fun isStaleSqliteConnection(throwable: Throwable): Boolean { + var current: Throwable? = throwable + while (current != null) { + val message = current.message?.lowercase().orEmpty() + if (message.contains("readonly") && message.contains("database")) return true + if (message.contains("sqlite_readonly_dbmoved")) return true + current = current.cause + } + return false + } + + fun rebindUnboundPartition(targetInstanceId: String) { + runCatching { + withMessageDatabaseLock { + rebindUnboundMigrationInstance(openDriverLocked(), targetInstanceId) + } + } + } + + /** Caller must hold the message-database lock via [withMessageDatabaseLock]. */ + private fun openDriverLocked(): SqlDriver { + driver?.let { return it } + return provideMessageDatabaseDriver().also { + ensureMessageDatabaseSchema(it) + driver = it + } } } - diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseSchemaGuard.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseSchemaGuard.kt new file mode 100644 index 0000000..ef528d1 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageDatabaseSchemaGuard.kt @@ -0,0 +1,486 @@ +package ru.fromchat.api.db + +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import kotlinx.coroutines.runBlocking +import ru.fromchat.core.Settings +import ru.fromchat.core.instance.isValidInstanceUuid + +/** + * Aligns on-disk SQLite with [MessageDatabaseExpectedSchema] by structural diff only + * (no user_version / schema version buckets). Full wipe is last resort. + */ +internal const val UNBOUND_MIGRATION_INSTANCE_ID = "00000000-0000-4000-8000-000000000001" + +internal fun ensureMessageDatabaseSchema(driver: SqlDriver) { + runCatching { migrateMessageDatabaseSchema(driver) } + .onFailure { recreateMessageDatabaseSchema(driver) } +} + +/** Reassign rows stored under [UNBOUND_MIGRATION_INSTANCE_ID] when a real instance id is known. */ +internal fun rebindUnboundMigrationInstance(driver: SqlDriver, targetInstanceId: String) { + val target = targetInstanceId.trim() + if (target.isEmpty() || !isValidInstanceUuid(target)) return + if (target == UNBOUND_MIGRATION_INSTANCE_ID) return + if (!tableExists(driver, "message")) return + val tablesWithInstance = listOf( + "message", + "conversation", + "attachment", + "outbox", + "profile_cache", + ) + inTransaction(driver) { + for (table in tablesWithInstance) { + if (!tableExists(driver, table)) continue + if (!columnExists(driver, table, "instanceId")) continue + driver.execute( + identifier = null, + sql = "UPDATE $table SET instanceId = ? WHERE instanceId = ?", + parameters = 2, + binders = { + bindString(0, target) + bindString(1, UNBOUND_MIGRATION_INSTANCE_ID) + }, + ) + } + } +} + +private data class ColumnAdd(val table: String, val column: String, val definition: String) + +private enum class PartitionedTableRebuild { + Message, + Conversation, + Attachment, +} + +private data class SchemaDiff( + val createTables: List = emptyList(), + val partitionRebuilds: List = emptyList(), + val addColumns: List = emptyList(), + val ensureIndexes: List> = emptyList(), + val dropIndexes: Set = emptySet(), + val unrecoverableReason: String? = null, +) + +private fun migrateMessageDatabaseSchema(driver: SqlDriver) { + val diff = computeSchemaDiff(driver) + diff.unrecoverableReason?.let { error(it) } + if (diff.isNoOp()) { + normalizeLegacyConversationIds(driver) + return + } + applySchemaDiff(driver, diff) + normalizeLegacyConversationIds(driver) +} + +private fun SchemaDiff.isNoOp(): Boolean = + createTables.isEmpty() && + partitionRebuilds.isEmpty() && + addColumns.isEmpty() && + ensureIndexes.isEmpty() && + dropIndexes.isEmpty() + +private fun computeSchemaDiff(driver: SqlDriver): SchemaDiff { + val tables = listTables(driver) + if (tables.isEmpty()) { + return SchemaDiff( + createTables = MessageDatabaseExpectedSchema.managedTables.toList(), + ensureIndexes = MessageDatabaseExpectedSchema.requiredIndexes.map { it.key to it.value }, + ) + } + + var createTables = mutableListOf() + var partitionRebuilds = mutableListOf() + var addColumns = mutableListOf() + var unrecoverable: String? = null + + fun fail(reason: String) { + if (unrecoverable == null) unrecoverable = reason + } + + for (tableName in MessageDatabaseExpectedSchema.managedTables) { + if (unrecoverable != null) break + val required = MessageDatabaseExpectedSchema.requiredColumns[tableName] ?: continue + if (!tables.contains(tableName)) { + createTables.add(tableName) + continue + } + + val actual = columnNames(driver, tableName) + if (tableName in MessageDatabaseExpectedSchema.partitionedTables && !actual.contains("instanceId")) { + if (!legacyPartitionCorePresent(tableName, actual)) { + fail("$tableName exists but lacks instanceId and recognizable legacy columns") + continue + } + partitionRebuilds.add(tableName.toPartitionRebuild()) + continue + } + + if (!partitionCorePresent(tableName, actual)) { + fail("$tableName is missing required columns: ${required.keys - actual}") + continue + } + + for ((column, definition) in required) { + if (actual.contains(column)) continue + if (column == "instanceId") continue + addColumns.add(ColumnAdd(tableName, column, definition)) + } + } + + if (unrecoverable != null) { + return SchemaDiff(unrecoverableReason = unrecoverable) + } + + val ensureIndexes = MessageDatabaseExpectedSchema.requiredIndexes + .filterNot { (name, _) -> indexExists(driver, name) } + .map { it.key to it.value } + + val dropIndexes = MessageDatabaseExpectedSchema.obsoleteIndexes + .filter { indexExists(driver, it) } + .toSet() + + return SchemaDiff( + createTables = createTables, + partitionRebuilds = partitionRebuilds.distinct(), + addColumns = addColumns, + ensureIndexes = ensureIndexes, + dropIndexes = dropIndexes, + ) +} + +private fun String.toPartitionRebuild(): PartitionedTableRebuild = + when (this) { + "message" -> PartitionedTableRebuild.Message + "conversation" -> PartitionedTableRebuild.Conversation + "attachment" -> PartitionedTableRebuild.Attachment + else -> error("Not a partitioned table: $this") + } + +private fun legacyPartitionCorePresent(table: String, actual: Set): Boolean = + when (table) { + "message" -> + actual.containsAll( + setOf( + "id", + "conversationId", + "userId", + "content", + "timestamp", + "isRead", + "isEdited", + "deletedFlag", + ), + ) + "conversation" -> actual.containsAll(setOf("id", "type")) + "attachment" -> actual.containsAll(setOf("id", "messageId", "conversationId", "status")) + else -> false + } + +private fun partitionCorePresent(table: String, actual: Set): Boolean { + val required = MessageDatabaseExpectedSchema.requiredColumns[table] ?: return true + val core = + when (table) { + "message" -> + setOf( + "instanceId", + "id", + "conversationId", + "userId", + "content", + "timestamp", + "isRead", + "isEdited", + "deletedFlag", + ) + "conversation" -> setOf("instanceId", "id", "type") + "attachment" -> setOf("instanceId", "id", "messageId", "conversationId", "status") + "server_binding" -> setOf("configKey", "activeInstanceId") + "instance_registry" -> setOf("instanceId", "firstSeenAt", "lastSeenAt") + "outbox" -> + setOf("instanceId", "clientMessageId", "conversationId", "kind", "payloadJson", "retryCount") + "profile_cache" -> setOf("instanceId", "userId", "json") + else -> required.keys + } + return core.all { actual.contains(it) } +} + +private fun applySchemaDiff(driver: SqlDriver, diff: SchemaDiff) { + val instanceId = migrationInstanceId() + inTransaction(driver) { + for (rebuild in diff.partitionRebuilds) { + when (rebuild) { + PartitionedTableRebuild.Message -> rebuildLegacyMessageTable(driver, instanceId) + PartitionedTableRebuild.Conversation -> rebuildLegacyConversationTable(driver, instanceId) + PartitionedTableRebuild.Attachment -> rebuildLegacyAttachmentTable(driver, instanceId) + } + } + for (table in diff.createTables) { + val sql = MessageDatabaseExpectedSchema.tableCreateSql[table] + ?: error("Missing CREATE SQL for $table") + driver.execute(identifier = null, sql = sql, parameters = 0) + } + for (add in diff.addColumns) { + if (!tableExists(driver, add.table)) continue + if (columnExists(driver, add.table, add.column)) continue + driver.execute( + identifier = null, + sql = "ALTER TABLE ${add.table} ADD COLUMN ${add.column} ${add.definition}", + parameters = 0, + ) + } + for (indexName in diff.dropIndexes) { + driver.execute(identifier = null, sql = "DROP INDEX IF EXISTS $indexName", parameters = 0) + } + for ((_, createSql) in diff.ensureIndexes) { + driver.execute(identifier = null, sql = createSql, parameters = 0) + } + } +} + +private fun applyExpectedSchema(driver: SqlDriver) { + for (table in MessageDatabaseExpectedSchema.managedTables) { + val sql = MessageDatabaseExpectedSchema.tableCreateSql[table] ?: continue + driver.execute(identifier = null, sql = sql, parameters = 0) + } + for ((_, createSql) in MessageDatabaseExpectedSchema.requiredIndexes) { + driver.execute(identifier = null, sql = createSql, parameters = 0) + } +} + +private fun normalizeLegacyConversationIds(driver: SqlDriver) { + if (!tableExists(driver, "message")) return + runCatching { + driver.execute( + identifier = null, + sql = "UPDATE message SET conversationId = '-1' WHERE conversationId = 'public'", + parameters = 0, + ) + } + if (!tableExists(driver, "conversation")) return + runCatching { + driver.execute( + identifier = null, + sql = "UPDATE conversation SET id = '-1' WHERE id = 'public'", + parameters = 0, + ) + } +} + +private fun migrationInstanceId(): String { + val known = runBlocking { Settings.lastKnownServerInstanceId.trim() } + if (known.isNotEmpty() && isValidInstanceUuid(known)) return known + return UNBOUND_MIGRATION_INSTANCE_ID +} + +private fun rebuildLegacyMessageTable(driver: SqlDriver, instanceId: String) { + driver.execute(identifier = null, sql = "DROP INDEX IF EXISTS message_conversation_index", parameters = 0) + driver.execute( + identifier = null, + sql = """ + CREATE TABLE message__migrate ( + instanceId TEXT NOT NULL, + id INTEGER NOT NULL, + conversationId TEXT NOT NULL, + userId INTEGER NOT NULL, + content TEXT NOT NULL, + timestamp TEXT NOT NULL, + isRead INTEGER NOT NULL, + isEdited INTEGER NOT NULL, + replyToId INTEGER, + clientMessageId TEXT, + deletedFlag INTEGER NOT NULL DEFAULT 0, + sendStatus TEXT, + PRIMARY KEY (instanceId, conversationId, id) + ) + """.trimIndent(), + parameters = 0, + ) + driver.execute( + identifier = null, + sql = """ + INSERT INTO message__migrate( + instanceId, id, conversationId, userId, content, timestamp, + isRead, isEdited, replyToId, clientMessageId, deletedFlag, sendStatus + ) + SELECT + ?, id, + CASE WHEN conversationId = 'public' THEN '-1' ELSE conversationId END, + userId, content, timestamp, isRead, isEdited, replyToId, clientMessageId, deletedFlag, + CASE WHEN id < 0 THEN 'pending' ELSE 'sent' END + FROM message + """.trimIndent(), + parameters = 1, + binders = { bindString(0, instanceId) }, + ) + driver.execute(identifier = null, sql = "DROP TABLE message", parameters = 0) + driver.execute(identifier = null, sql = "ALTER TABLE message__migrate RENAME TO message", parameters = 0) + driver.execute( + identifier = null, + sql = MessageDatabaseExpectedSchema.requiredIndexes.getValue("message_instance_conversation_index"), + parameters = 0, + ) +} + +private fun rebuildLegacyConversationTable(driver: SqlDriver, instanceId: String) { + driver.execute( + identifier = null, + sql = """ + CREATE TABLE conversation__migrate ( + instanceId TEXT NOT NULL, + id TEXT NOT NULL, + type TEXT NOT NULL, + otherUserId INTEGER, + displayName TEXT, + lastMessageId INTEGER, + lastMessagePreview TEXT, + unreadCount INTEGER NOT NULL DEFAULT 0, + updatedAt TEXT, + PRIMARY KEY (instanceId, id) + ) + """.trimIndent(), + parameters = 0, + ) + driver.execute( + identifier = null, + sql = """ + INSERT INTO conversation__migrate( + instanceId, id, type, otherUserId, displayName, + lastMessageId, lastMessagePreview, unreadCount, updatedAt + ) + SELECT + ?, + CASE WHEN id = 'public' THEN '-1' ELSE id END, + type, otherUserId, displayName, + lastMessageId, lastMessagePreview, unreadCount, updatedAt + FROM conversation + """.trimIndent(), + parameters = 1, + binders = { bindString(0, instanceId) }, + ) + driver.execute(identifier = null, sql = "DROP TABLE conversation", parameters = 0) + driver.execute(identifier = null, sql = "ALTER TABLE conversation__migrate RENAME TO conversation", parameters = 0) +} + +private fun rebuildLegacyAttachmentTable(driver: SqlDriver, instanceId: String) { + driver.execute( + identifier = null, + sql = """ + CREATE TABLE attachment__migrate ( + instanceId TEXT NOT NULL, + id INTEGER NOT NULL, + messageId INTEGER NOT NULL, + conversationId TEXT NOT NULL, + remotePath TEXT, + localPath TEXT, + status TEXT NOT NULL, + blurhash TEXT, + aspectRatio REAL, + size INTEGER, + bytesTransferred INTEGER NOT NULL DEFAULT 0, + clientMessageId TEXT, + PRIMARY KEY (instanceId, id, messageId, conversationId) + ) + """.trimIndent(), + parameters = 0, + ) + driver.execute( + identifier = null, + sql = """ + INSERT INTO attachment__migrate( + instanceId, id, messageId, conversationId, remotePath, localPath, + status, blurhash, aspectRatio, size, bytesTransferred, clientMessageId + ) + SELECT + ?, id, messageId, + CASE WHEN conversationId = 'public' THEN '-1' ELSE conversationId END, + remotePath, localPath, status, blurhash, aspectRatio, size, 0, NULL + FROM attachment + """.trimIndent(), + parameters = 1, + binders = { bindString(0, instanceId) }, + ) + driver.execute(identifier = null, sql = "DROP TABLE attachment", parameters = 0) + driver.execute(identifier = null, sql = "ALTER TABLE attachment__migrate RENAME TO attachment", parameters = 0) +} + +private fun recreateMessageDatabaseSchema(driver: SqlDriver) { + val drops = listOf( + "DROP INDEX IF EXISTS message_instance_conversation_index", + "DROP INDEX IF EXISTS message_conversation_index", + "DROP TABLE IF EXISTS message", + "DROP TABLE IF EXISTS message__migrate", + "DROP TABLE IF EXISTS attachment", + "DROP TABLE IF EXISTS attachment__migrate", + "DROP TABLE IF EXISTS outbox", + "DROP TABLE IF EXISTS profile_cache", + "DROP TABLE IF EXISTS conversation", + "DROP TABLE IF EXISTS conversation__migrate", + "DROP TABLE IF EXISTS server_binding", + "DROP TABLE IF EXISTS instance_registry", + ) + for (sql in drops) { + driver.execute(identifier = null, sql = sql, parameters = 0) + } + applyExpectedSchema(driver) +} + +private fun inTransaction(driver: SqlDriver, block: () -> Unit) { + driver.execute(identifier = null, sql = "BEGIN IMMEDIATE", parameters = 0) + try { + block() + driver.execute(identifier = null, sql = "COMMIT", parameters = 0) + } catch (e: Exception) { + runCatching { driver.execute(identifier = null, sql = "ROLLBACK", parameters = 0) } + throw e + } +} + +private fun listTables(driver: SqlDriver): Set { + val names = mutableSetOf() + driver.executeQuery( + identifier = null, + sql = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", + mapper = { cursor -> + while (cursor.next().value) { + names.add(cursor.getString(0)!!) + } + QueryResult.Value(Unit) + }, + parameters = 0, + ) + return names +} + +private fun columnNames(driver: SqlDriver, table: String): Set { + val names = mutableSetOf() + driver.executeQuery( + identifier = null, + sql = "PRAGMA table_info($table)", + mapper = { cursor -> + while (cursor.next().value) { + names.add(cursor.getString(1)!!) + } + QueryResult.Value(Unit) + }, + parameters = 0, + ) + return names +} + +private fun tableExists(driver: SqlDriver, table: String): Boolean = + listTables(driver).contains(table) + +private fun columnExists(driver: SqlDriver, table: String, column: String): Boolean = + columnNames(driver, table).contains(column) + +private fun indexExists(driver: SqlDriver, indexName: String): Boolean = + driver.executeQuery( + identifier = null, + sql = "SELECT 1 FROM sqlite_master WHERE type='index' AND name=? LIMIT 1", + mapper = { cursor -> QueryResult.Value(cursor.next().value) }, + parameters = 1, + binders = { bindString(0, indexName) }, + ).value diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageRepository.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageRepository.kt new file mode 100644 index 0000000..9d828ad --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageRepository.kt @@ -0,0 +1,69 @@ +package ru.fromchat.api.db + +import kotlinx.coroutines.flow.Flow +import ru.fromchat.api.Message +import ru.fromchat.core.cache.CacheContext + +/** + * Instance-scoped message access for UI and send pipeline. + */ +object MessageRepository { + private fun activeInstance(): String = CacheContext.requireActiveInstanceId() + + fun observeMessages(conversationId: String): Flow> = + MessageCacheStore.observeMessages(activeInstance(), conversationId) + + fun observePublicMessages(): Flow> = + observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)) + + fun observeDmMessages(otherUserId: Int): Flow> = + observeMessages(conversationIdForDm(otherUserId)) + + suspend fun loadPublicMessages(): List = MessageCacheStore.loadPublicMessages() + + suspend fun loadRecentPublicMessages(limit: Long): List = + MessageCacheStore.loadRecentPublicMessages(limit) + + suspend fun replacePublicMessages(messages: List) = + MessageCacheStore.replacePublicMessages(messages) + + suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message) + + suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) = + MessageCacheStore.confirmPublicMessage(clientMessageId, confirmed) + + suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) = + MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId) + + suspend fun markMessageDeleted(conversationId: String, messageId: Int) = + MessageCacheStore.markMessageDeleted(conversationId, messageId) + + suspend fun markPublicMessageDeleted(messageId: Int) = + markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId) + + suspend fun loadDmMessages(otherUserId: Int): List = + MessageCacheStore.loadDmMessages(otherUserId) + + suspend fun replaceDmMessages(otherUserId: Int, messages: List) = + MessageCacheStore.replaceDmMessages(otherUserId, messages) + + suspend fun upsertDmMessage(otherUserId: Int, message: Message) = + MessageCacheStore.upsertDmMessage(otherUserId, message) + + suspend fun confirmDmMessage(otherUserId: Int, clientMessageId: String, confirmed: Message) = + MessageCacheStore.confirmDmMessage(otherUserId, clientMessageId, confirmed) + + suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) = + MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId) + + suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) = + MessageCacheStore.deleteDmMessageById(otherUserId, messageId) + + suspend fun replaceDmConversations(conversations: List) = + MessageCacheStore.replaceDmConversations(conversations) + + suspend fun loadCachedDmConversations(): List = + MessageCacheStore.loadCachedDmConversations() + + suspend fun clearAllCache() = MessageCacheStore.clearAll() +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ProfileCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ProfileCacheStore.kt new file mode 100644 index 0000000..5e68d29 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/ProfileCacheStore.kt @@ -0,0 +1,61 @@ +package ru.fromchat.api.db + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import ru.fromchat.api.UserProfile + +object ProfileCacheStore { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } + + suspend fun get(instanceId: String, userId: Int): UserProfile? = withContext(Dispatchers.Default) { + val id = instanceId.trim() + if (id.isEmpty()) return@withContext null + val raw = MessageDatabaseProvider.database.messageDatabaseQueries + .selectProfileCache(id, userId.toLong()) + .executeAsOneOrNull() ?: return@withContext null + runCatching { json.decodeFromString(UserProfile.serializer(), raw) }.getOrNull() + } + + suspend fun put(instanceId: String, profile: UserProfile) { + val id = instanceId.trim() + if (id.isEmpty()) return + withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries.upsertProfileCache( + instanceId = id, + userId = profile.id.toLong(), + json = json.encodeToString(UserProfile.serializer(), profile), + ) + } + } + + suspend fun remove(instanceId: String, userId: Int) { + val id = instanceId.trim() + if (id.isEmpty()) return + withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries.deleteProfileCache( + instanceId = id, + userId = userId.toLong(), + ) + } + } + + suspend fun loadAllForInstance(instanceId: String): Map = + withContext(Dispatchers.Default) { + val id = instanceId.trim() + if (id.isEmpty()) return@withContext emptyMap() + MessageDatabaseProvider.database.messageDatabaseQueries + .selectAllProfilesForInstance(id) + .executeAsList() + .mapNotNull { row -> + runCatching { + json.decodeFromString(UserProfile.serializer(), row.json) to row.userId.toInt() + }.getOrNull() + } + .associate { (profile, uid) -> uid to profile } + } +} 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 new file mode 100644 index 0000000..d48ef6e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/DmAttachmentOutboxHandler.kt @@ -0,0 +1,400 @@ +package ru.fromchat.api.outbox + +import com.pr0gramm3r101.utils.crypto.Base64 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import ru.fromchat.api.ApiClient +import ru.fromchat.api.AttachmentUploadNotifier +import ru.fromchat.api.AttachmentUploadProgress +import ru.fromchat.api.DmUploadCompleteResponse +import ru.fromchat.api.SendDmFile +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.clearUploadSecretsOnly +import ru.fromchat.api.optimisticMessageIdForClientMessageId +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.readOutboundFileBytes +import ru.fromchat.core.cache.saveEncryptedUploadBlob +import ru.fromchat.core.cache.saveUploadTransportCipherJson +import ru.fromchat.core.cache.stageOutboundFileForUpload +import ru.fromchat.crypto.transport.TransportCiphertext +import ru.fromchat.crypto.transport.TransportCrypto +import ru.fromchat.db.Outbox + +private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024 +private const val DEFAULT_CHUNK_SIZE = 262_144 + +@Serializable +private data class StoredTransportCipher( + val clientPublicKeyB64: String, + val nonceB64: String, + val ciphertextB64: String, +) + +object DmAttachmentOutboxHandler { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + private fun outboxRowExists(instanceId: String, clientMessageId: String): Boolean = + MessageDatabaseProvider.database.messageDatabaseQueries + .selectOutboxItem(instanceId, clientMessageId) + .executeAsOneOrNull() != null + + private fun ensureStillQueued(instanceId: String, clientMessageId: String) { + if (!outboxRowExists(instanceId, clientMessageId)) { + AttachmentMediaLog.upload("cancelled_in_flight", "job" to clientMessageId) + throw kotlinx.coroutines.CancellationException("Upload cancelled") + } + } + + suspend fun process(row: Outbox): Boolean { + if (row.kind != OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT) return false + val instanceId = row.instanceId + val payload = json.decodeFromString(row.payloadJson) + val clientMessageId = payload.clientMessageId.trim() + if (clientMessageId.isEmpty() || payload.recipientId <= 0) return true + if (!outboxRowExists(instanceId, clientMessageId)) return true + + val conversationId = row.conversationId.ifBlank { + conversationIdForDm(payload.recipientId) + } + if (MessageCacheStore.hasSentMessageWithClientId(conversationId, clientMessageId)) { + withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem( + instanceId, + clientMessageId, + ) + MessageDatabaseProvider.database.messageDatabaseQueries.deletePendingMessageByClientMessageId( + instanceId, + conversationId, + clientMessageId, + ) + } + clearPrepared(instanceId, clientMessageId) + clearUploadSecretsOnly(instanceId, clientMessageId) + return true + } + + val serverUploadId = arrayOf(payload.uploadId.trim()) + return runCatching { + ensureStillQueued(instanceId, clientMessageId) + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.Pending(clientMessageId, payload.filename), + messageLabel = payload.plaintext, + ) + val stagedPayload = ensureStagedPayload(instanceId, row, payload) + ensureStillQueued(instanceId, clientMessageId) + val restoredPercent = uploadPercent(row.bytesUploaded, stagedPayload) + if (restoredPercent > 0) { + emitProgress(clientMessageId, restoredPercent, stagedPayload.filename, stagedPayload.plaintext) + } else { + 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()) + } + } 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, + ) + 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) + } + } + + ensureStillQueued(instanceId, clientMessageId) + if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) { + sendInline(activePayload, encryptedBlob, msgCipher) + } else { + sendResumable( + instanceId = instanceId, + row = row, + payload = activePayload, + encryptedBlob = encryptedBlob, + msgCipher = msgCipher, + serverUploadId = serverUploadId, + ) + } + ensureStillQueued(instanceId, clientMessageId) + + clearPrepared(instanceId, clientMessageId) + withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( + instanceId = instanceId, + clientMessageId = clientMessageId, + conversationId = row.conversationId, + kind = OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK, + payloadJson = row.payloadJson, + retryCount = row.retryCount, + nextAttemptAt = null, + bytesUploaded = row.bytesUploaded, + ) + } + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.Success(clientMessageId), + messageLabel = payload.plaintext, + ) + true + }.getOrElse { error -> + if (error is kotlinx.coroutines.CancellationException) { + OutgoingMessageCoordinator.abortDmServerUploadIfNeeded(serverUploadId[0]) + return true + } + if (error.isOutboundFileUnavailable()) { + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.Failed( + jobId = clientMessageId, + error = error.message ?: "Attachment unavailable", + ), + messageLabel = payload.plaintext, + ) + runCatching { + clearOutboundImageCaches( + clientMessageId, + optimisticMessageIdForClientMessageId(clientMessageId), + ) + OutgoingMessageCoordinator.cancelOutboundMessage(clientMessageId, row.conversationId) + } + return true + } + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.Failed( + jobId = clientMessageId, + error = error.message ?: "Upload failed", + ), + messageLabel = payload.plaintext, + ) + false + } + } + + private suspend fun sendInline( + payload: DmAttachmentOutboxPayload, + encryptedBlob: ByteArray, + msgCipher: TransportCiphertext, + ) { + val file = SendDmFile( + encryptedFileDataB64 = Base64.encode(encryptedBlob), + filename = payload.filename, + fileSize = encryptedBlob.size.toLong(), + ) + ApiClient.sendDm( + recipientId = payload.recipientId, + plaintext = payload.plaintext, + clientMessageId = payload.clientMessageId, + replyToId = payload.replyToId, + transportFiles = listOf(file), + preparedTransport = msgCipher, + ) + } + + private suspend fun sendResumable( + instanceId: String, + row: Outbox, + payload: DmAttachmentOutboxPayload, + encryptedBlob: ByteArray, + msgCipher: TransportCiphertext, + serverUploadId: Array, + ) { + var uploadId = payload.uploadId.trim().ifBlank { serverUploadId[0] } + try { + if (uploadId.isEmpty()) { + val init = ApiClient.initDmUpload( + filename = payload.filename, + totalSize = encryptedBlob.size.toLong(), + recipientId = payload.recipientId, + chunkSize = DEFAULT_CHUNK_SIZE, + ) + uploadId = init.uploadId + serverUploadId[0] = uploadId + persistPayloadProgress(instanceId, row, payload.copy(uploadId = uploadId), row.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) + ApiClient.uploadDmChunk( + uploadId = uploadId, + offset = offset.toLong(), + dataB64 = Base64.encode(chunk), + ) + offset = nextOffset + val percent = ((offset.toDouble() / encryptedBlob.size.toDouble()) * 100.0).toInt() + emitProgress(payload.clientMessageId, percent, payload.filename, payload.plaintext) + persistPayloadProgress( + instanceId, + row, + payload.copy(uploadId = uploadId), + offset.toLong(), + ) + } + + val completed: DmUploadCompleteResponse = ApiClient.completeDmUpload(uploadId) + ApiClient.sendDm( + recipientId = payload.recipientId, + plaintext = payload.plaintext, + clientMessageId = payload.clientMessageId, + replyToId = payload.replyToId, + uploadedFileIds = listOf(completed.fileId), + preparedTransport = msgCipher, + ) + serverUploadId[0] = "" + } catch (e: kotlinx.coroutines.CancellationException) { + OutgoingMessageCoordinator.abortDmServerUploadIfNeeded(uploadId) + serverUploadId[0] = "" + throw e + } + } + + private suspend fun ensureStagedPayload( + instanceId: String, + row: Outbox, + payload: DmAttachmentOutboxPayload, + ): DmAttachmentOutboxPayload { + val staged = stageOutboundFileForUpload(instanceId, payload.clientMessageId, payload.fileUri) + 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) + persistPayloadProgress(instanceId, row, updated, row.bytesUploaded) + return updated + } + + private suspend fun persistPayloadProgress( + instanceId: String, + row: Outbox, + payload: DmAttachmentOutboxPayload, + bytesUploaded: Long, + ) { + if (!outboxRowExists(instanceId, row.clientMessageId)) return + withContext(Dispatchers.Default) { + if (!outboxRowExists(instanceId, row.clientMessageId)) return@withContext + MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( + instanceId = instanceId, + clientMessageId = row.clientMessageId, + conversationId = row.conversationId, + kind = row.kind, + payloadJson = json.encodeToString(payload), + retryCount = row.retryCount, + nextAttemptAt = row.nextAttemptAt, + bytesUploaded = bytesUploaded, + ) + } + } + + private suspend fun loadPrepared( + instanceId: String, + clientMessageId: String, + ): Pair? { + val blob = loadEncryptedUploadBlob(instanceId, clientMessageId) ?: return null + val raw = loadUploadTransportCipherJson(instanceId, clientMessageId) ?: return null + return runCatching { + val stored = json.decodeFromString(raw) + val cipher = TransportCiphertext( + clientPublicKeyB64 = stored.clientPublicKeyB64, + nonceB64 = stored.nonceB64, + ciphertextB64 = stored.ciphertextB64, + ) + blob to cipher + }.getOrNull() + } + + private suspend fun savePrepared( + instanceId: String, + clientMessageId: String, + 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)) + } + + private suspend fun clearPrepared(instanceId: String, clientMessageId: String) { + clearUploadSecretsOnly(instanceId, clientMessageId) + } + + private fun uploadPercent(bytesUploaded: Long, payload: DmAttachmentOutboxPayload): Int { + val total = when { + payload.encryptedFileSizeBytes > 0L -> payload.encryptedFileSizeBytes + payload.fileSizeBytes > 0L -> payload.fileSizeBytes + else -> 0L + } + if (total <= 0L || bytesUploaded <= 0L) return 0 + return ((bytesUploaded.toDouble() / total.toDouble()) * 100.0).toInt().coerceIn(0, 99) + } + + private fun emitProgress( + jobId: String, + percent: Int, + filename: String? = null, + messageLabel: String? = null, + ) { + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.InProgress( + jobId = jobId, + percent = percent.coerceIn(0, 100), + filename = filename, + ), + messageLabel = messageLabel, + ) + } + + suspend fun hasPendingAttachmentWork(instanceId: String): Boolean = + withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries + .selectPendingOutboxForInstance(instanceId) + .executeAsList() + .any { it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxPayloads.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxPayloads.kt new file mode 100644 index 0000000..0e2767c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxPayloads.kt @@ -0,0 +1,35 @@ +package ru.fromchat.api.outbox + +import kotlinx.serialization.Serializable +import ru.fromchat.api.SendDmFile + +@Serializable +data class PublicOutboxPayload( + val content: String, + val replyToId: Int? = null, +) + +@Serializable +data class DmOutboxPayload( + val recipientId: Int, + val plaintext: String, + val clientMessageId: String? = null, + val replyToId: Int? = null, + val transportFiles: List = emptyList(), + val uploadedFileIds: List = emptyList(), +) + +@Serializable +data class DmAttachmentOutboxPayload( + val recipientId: Int, + val plaintext: String, + val clientMessageId: String, + val replyToId: Int? = null, + val fileUri: String, + val filename: String, + val fileSizeBytes: Long = 0L, + val aspectRatio: Float? = null, + /** Encrypted blob size; used for upload progress after encryption. */ + val encryptedFileSizeBytes: Long = 0L, + val uploadId: String = "", +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.kt new file mode 100644 index 0000000..6adeba6 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.outbox + +/** + * Drains pending outbox rows for the active instance (Android: WorkManager; iOS: BG task hook). + */ +expect fun scheduleOutboxProcessing(instanceId: String) + +expect fun cancelOutboxProcessing(instanceId: 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 new file mode 100644 index 0000000..a8a9d21 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/outbox/OutgoingMessageCoordinator.kt @@ -0,0 +1,307 @@ +package ru.fromchat.api.outbox + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import ru.fromchat.api.AttachmentUploadNotifier +import ru.fromchat.api.AttachmentUploadProgress +import ru.fromchat.ui.chat.AttachmentMediaLog +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import ru.fromchat.api.ApiClient +import ru.fromchat.api.Message +import ru.fromchat.api.db.MessageCacheStore +import ru.fromchat.api.db.MessageDatabaseProvider +import ru.fromchat.api.db.MessageRepository +import ru.fromchat.api.db.conversationIdForDm +import ru.fromchat.api.db.conversationIdForGroup +import ru.fromchat.api.db.GENERAL_PUBLIC_GROUP_ID +import ru.fromchat.core.cache.CacheContext +import ru.fromchat.core.cache.clearUploadArtifacts +import ru.fromchat.core.cache.clearUploadSecretsOnly +import ru.fromchat.core.cache.readOutboundFileBytes + +/** + * Single entry point for enqueueing outbound messages (DB row + outbox + worker). + * Panels must not call [ApiClient.sendMessage] / [ApiClient.sendDm] directly. + */ +object OutgoingMessageCoordinator { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val drainMutex = Mutex() + private val drainScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private fun kickOutboxDrain(instanceId: String) { + val id = instanceId.trim() + if (id.isEmpty()) return + scheduleOutboxProcessing(id) + drainScope.launch { drainOutboxForInstance(id) } + } + + suspend fun enqueuePublicMessage( + content: String, + replyToId: Int?, + clientMessageId: String, + optimisticMessage: Message, + ) { + val instanceId = CacheContext.requireActiveInstanceId() + val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID) + withContext(Dispatchers.Default) { + MessageRepository.upsertPublicMessage(optimisticMessage) + val payload = json.encodeToString(PublicOutboxPayload(content, replyToId)) + MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( + instanceId = instanceId, + clientMessageId = clientMessageId, + conversationId = conversationId, + kind = KIND_SEND_PUBLIC, + payloadJson = payload, + retryCount = 0L, + nextAttemptAt = null, + bytesUploaded = 0L, + ) + } + kickOutboxDrain(instanceId) + } + + suspend fun enqueueDmMessage( + recipientId: Int, + plaintext: String, + clientMessageId: String, + replyToId: Int?, + optimisticMessage: Message, + transportFiles: List = emptyList(), + uploadedFileIds: List = emptyList(), + ) { + val instanceId = CacheContext.requireActiveInstanceId() + val conversationId = conversationIdForDm(recipientId) + withContext(Dispatchers.Default) { + MessageRepository.upsertDmMessage(recipientId, optimisticMessage) + val payload = json.encodeToString( + DmOutboxPayload( + recipientId = recipientId, + plaintext = plaintext, + clientMessageId = clientMessageId, + replyToId = replyToId, + transportFiles = transportFiles, + uploadedFileIds = uploadedFileIds, + ), + ) + MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( + instanceId = instanceId, + clientMessageId = clientMessageId, + conversationId = conversationId, + kind = KIND_SEND_DM, + payloadJson = payload, + retryCount = 0L, + nextAttemptAt = null, + bytesUploaded = 0L, + ) + } + kickOutboxDrain(instanceId) + } + + suspend fun enqueueDmAttachment( + recipientId: Int, + plaintext: String, + clientMessageId: String, + replyToId: Int?, + fileUri: String, + filename: String, + optimisticMessage: Message, + aspectRatio: Float? = null, + ) { + val instanceId = CacheContext.requireActiveInstanceId() + val conversationId = conversationIdForDm(recipientId) + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.Pending(clientMessageId, filename), + messageLabel = plaintext, + ) + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.InProgress(clientMessageId, 1, filename), + messageLabel = plaintext, + ) + withContext(Dispatchers.Default) { + MessageRepository.upsertDmMessage(recipientId, optimisticMessage) + val payload = json.encodeToString( + DmAttachmentOutboxPayload( + recipientId = recipientId, + plaintext = plaintext, + clientMessageId = clientMessageId, + replyToId = replyToId, + fileUri = fileUri, + filename = filename, + fileSizeBytes = 0L, + aspectRatio = aspectRatio?.takeIf { it > 0f }, + ), + ) + MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( + instanceId = instanceId, + clientMessageId = clientMessageId, + conversationId = conversationId, + kind = KIND_SEND_DM_ATTACHMENT, + payloadJson = payload, + retryCount = 0L, + nextAttemptAt = null, + bytesUploaded = 0L, + ) + } + kickOutboxDrain(instanceId) + } + + suspend fun clearAttachmentOutboxAfterAck(clientMessageId: String) { + val cid = clientMessageId.trim() + if (cid.isEmpty()) return + val instanceId = CacheContext.requireActiveInstanceId() + withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem(instanceId, cid) + } + } + + /** Best-effort DELETE of an in-progress resumable upload session on the server. */ + suspend fun abortDmServerUploadIfNeeded(uploadId: String) { + val id = uploadId.trim() + if (id.isEmpty()) return + runCatching { ApiClient.abortDmUpload(id) } + .onSuccess { + AttachmentMediaLog.upload("server_abort_ok", "uploadId" to id) + } + .onFailure { error -> + AttachmentMediaLog.upload( + "server_abort_failed", + "uploadId" to id, + "error" to (error.message ?: "unknown"), + ) + } + } + + /** Drops a queued outbound row, local message, and any upload artifacts. */ + suspend fun cancelOutboundMessage(clientMessageId: String, conversationId: String) { + val cid = clientMessageId.trim() + if (cid.isEmpty()) return + val instanceId = CacheContext.requireActiveInstanceId() + AttachmentMediaLog.upload("cancel_requested", "job" to cid, "conv" to conversationId) + withContext(Dispatchers.Default) { + val row = MessageDatabaseProvider.database.messageDatabaseQueries + .selectOutboxItem(instanceId, cid) + .executeAsOneOrNull() + if (row?.kind == KIND_SEND_DM_ATTACHMENT) { + runCatching { + val payload = json.decodeFromString(row.payloadJson) + abortDmServerUploadIfNeeded(payload.uploadId) + } + } + MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem(instanceId, cid) + MessageCacheStore.deleteMessageByClientMessageId(conversationId, cid) + clearUploadArtifacts(instanceId, cid) + } + AttachmentUploadNotifier.emit( + AttachmentUploadProgress.Failed(cid, "Cancelled"), + ) + kickOutboxDrain(instanceId) + } + + /** Drains pending outbox rows for the active instance (shared by workers and iOS). */ + suspend fun drainActiveInstanceOutbox(): Boolean = + drainOutboxForInstance(CacheContext.activeInstanceId.value.trim()) + + /** Drains pending outbox rows for [instanceId]. Returns false if any attachment upload failed. */ + suspend fun drainOutboxForInstance(instanceId: String): Boolean { + val id = instanceId.trim() + if (id.isEmpty()) return true + return drainMutex.withLock { + pruneStaleAttachmentOutbox(id) + val rows = withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries + .selectPendingOutboxForInstance(id) + .executeAsList() + } + var allOk = true + for (row in rows) { + when (row.kind) { + KIND_SEND_PUBLIC -> { + runCatching { + val payload = json.decodeFromString(row.payloadJson) + ApiClient.sendMessageViaHttp(payload.content, payload.replyToId) + withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries + .deleteOutboxItem(id, row.clientMessageId) + } + } + } + KIND_SEND_DM -> { + runCatching { + val payload = json.decodeFromString(row.payloadJson) + ApiClient.sendDm( + recipientId = payload.recipientId, + plaintext = payload.plaintext, + clientMessageId = payload.clientMessageId, + replyToId = payload.replyToId, + transportFiles = payload.transportFiles, + uploadedFileIds = payload.uploadedFileIds, + ) + withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries + .deleteOutboxItem(id, row.clientMessageId) + } + } + } + KIND_SEND_DM_ATTACHMENT -> { + if (!DmAttachmentOutboxHandler.process(row)) { + allOk = false + drainScope.launch { + delay(3_000) + drainOutboxForInstance(id) + } + } + } + KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> Unit + } + } + allOk + } + } + + const val KIND_SEND_PUBLIC = "send_public" + const val KIND_SEND_DM = "send_dm" + const val KIND_SEND_DM_ATTACHMENT = "send_dm_attachment" + /** Upload+send finished; row kept until DM ack so UI can still resolve local preview from outbox. */ + const val KIND_SEND_DM_ATTACHMENT_AWAITING_ACK = "send_dm_attachment_awaiting_ack" + + suspend fun pruneStaleAttachmentOutboxForInstance(instanceId: String) { + val id = instanceId.trim() + if (id.isEmpty()) return + pruneStaleAttachmentOutbox(id) + } + + private suspend fun pruneStaleAttachmentOutbox(instanceId: String) { + withContext(Dispatchers.Default) { + val db = MessageDatabaseProvider.database.messageDatabaseQueries + val rows = db.selectPendingOutboxForInstance(instanceId).executeAsList() + for (row in rows) { + when (row.kind) { + KIND_SEND_DM_ATTACHMENT, + KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> { + if (!MessageCacheStore.hasSentMessageWithClientId( + row.conversationId, + row.clientMessageId, + ) + ) { + continue + } + db.deleteOutboxItem(instanceId, row.clientMessageId) + db.deletePendingMessageByClientMessageId( + instanceId, + row.conversationId, + row.clientMessageId, + ) + clearUploadSecretsOnly(instanceId, row.clientMessageId) + } + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/ServerConfigKeys.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/ServerConfigKeys.kt new file mode 100644 index 0000000..cb5d227 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/ServerConfigKeys.kt @@ -0,0 +1,6 @@ +package ru.fromchat.core + +fun ServerConfigData.configKey(): String { + val scheme = if (httpsEnabled) "1" else "0" + return "${serverIp.trim().lowercase()}|$apiPort|$scheme" +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt index 9318e0c..04cc448 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import ru.fromchat.api.DeviceSessionInfo @@ -106,45 +107,57 @@ object Settings { throw IllegalStateException("Server config not initialized") var serverConfig: ServerConfigData - get() = runBlocking { - migrateLegacyServerUrlIfNeeded() - if (!settings.contains(SERVER_IP_KEY)) { - settings.putString(SERVER_IP_KEY, "fromchat.ru") - settings.putInt(API_PORT_KEY, 443) - settings.putInt(CALLS_PORT_KEY, DEFAULT_CALLS_PORT) - if (!settings.contains(HTTPS_ENABLED_KEY)) { - settings.putBoolean(HTTPS_ENABLED_KEY, true) - } - } - val ip = settings.getString(SERVER_IP_KEY) - if (ip.isBlank()) { - serverConfigNotInitialized() - } - val apiPort = settings.getInt(API_PORT_KEY, 443).coerceIn(1, 65535) - val rawCalls = settings.getInt(CALLS_PORT_KEY, -1) - val callsPort = if (rawCalls in 1..65535) rawCalls else DEFAULT_CALLS_PORT - val https = settings.getBoolean(HTTPS_ENABLED_KEY, true) - val callsEnabled = - if (settings.contains(CALLS_ENABLED_KEY)) { - settings.getBoolean(CALLS_ENABLED_KEY, true) - } else { - true - } - ServerConfigData( - serverIp = ip, - apiPort = apiPort, - callsPort = callsPort, - httpsEnabled = https, - callsEnabled = callsEnabled, - ) + get() = runBlocking { readServerConfig() } + set(value) { + runIO { writeServerConfig(value) } } - set(value) = runIO { - settings.putString(SERVER_IP_KEY, value.serverIp.trim()) - settings.putInt(API_PORT_KEY, value.apiPort.coerceIn(1, 65535)) - settings.putInt(CALLS_PORT_KEY, value.callsPort.coerceIn(1, 65535)) - settings.putBoolean(HTTPS_ENABLED_KEY, value.httpsEnabled) - settings.putBoolean(CALLS_ENABLED_KEY, value.callsEnabled) + + suspend fun setServerConfig(value: ServerConfigData) { + withContext(Dispatchers.IO) { + writeServerConfig(value) } + } + + suspend fun readServerConfig(): ServerConfigData { + migrateLegacyServerUrlIfNeeded() + if (!settings.contains(SERVER_IP_KEY)) { + settings.putString(SERVER_IP_KEY, "fromchat.ru") + settings.putInt(API_PORT_KEY, 443) + settings.putInt(CALLS_PORT_KEY, DEFAULT_CALLS_PORT) + if (!settings.contains(HTTPS_ENABLED_KEY)) { + settings.putBoolean(HTTPS_ENABLED_KEY, true) + } + } + val ip = settings.getString(SERVER_IP_KEY) + if (ip.isBlank()) { + serverConfigNotInitialized() + } + val apiPort = settings.getInt(API_PORT_KEY, 443).coerceIn(1, 65535) + val rawCalls = settings.getInt(CALLS_PORT_KEY, -1) + val callsPort = if (rawCalls in 1..65535) rawCalls else DEFAULT_CALLS_PORT + val https = settings.getBoolean(HTTPS_ENABLED_KEY, true) + val callsEnabled = + if (settings.contains(CALLS_ENABLED_KEY)) { + settings.getBoolean(CALLS_ENABLED_KEY, true) + } else { + true + } + return ServerConfigData( + serverIp = ip, + apiPort = apiPort, + callsPort = callsPort, + httpsEnabled = https, + callsEnabled = callsEnabled, + ) + } + + private suspend fun writeServerConfig(value: ServerConfigData) { + settings.putString(SERVER_IP_KEY, value.serverIp.trim()) + settings.putInt(API_PORT_KEY, value.apiPort.coerceIn(1, 65535)) + settings.putInt(CALLS_PORT_KEY, value.callsPort.coerceIn(1, 65535)) + settings.putBoolean(HTTPS_ENABLED_KEY, value.httpsEnabled) + settings.putBoolean(CALLS_ENABLED_KEY, value.callsEnabled) + } /** Cached device sessions (JSON). Shown immediately while refreshing from the network. */ fun readDeviceSessionsCache(): List? = runBlocking { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheContext.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheContext.kt new file mode 100644 index 0000000..b786674 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheContext.kt @@ -0,0 +1,47 @@ +package ru.fromchat.core.cache + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import ru.fromchat.api.ProfileCache +import ru.fromchat.api.db.MessageDatabaseProvider +import ru.fromchat.ui.chat.PublicChatPanelCache +import ru.fromchat.ui.dm.DmPanelCache + +/** + * Active server instance partition for reads/writes. + * All repository queries must use [activeInstanceId] (non-empty when session is valid). + */ +object CacheContext { + private val _activeInstanceId = MutableStateFlow("") + val activeInstanceId: StateFlow = _activeInstanceId.asStateFlow() + + private val _activeUserId = MutableStateFlow(null) + val activeUserId: StateFlow = _activeUserId.asStateFlow() + + fun setActiveInstance(instanceId: String, userId: Int?) { + val trimmed = instanceId.trim() + val changed = _activeInstanceId.value != trimmed + _activeInstanceId.value = trimmed + _activeUserId.value = userId + if (changed) { + ProfileCache.onActiveInstanceChanged(trimmed) + PublicChatPanelCache.onActiveInstanceChanged(trimmed) + DmPanelCache.onActiveInstanceChanged(trimmed) + if (trimmed.isNotEmpty()) { + MessageDatabaseProvider.rebindUnboundPartition(trimmed) + } + } + } + + fun requireActiveInstanceId(): String { + val id = _activeInstanceId.value.trim() + require(id.isNotEmpty()) { "No active server instance id" } + return id + } + + fun clearActive() { + _activeInstanceId.value = "" + _activeUserId.value = null + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheValidator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheValidator.kt new file mode 100644 index 0000000..c21e00c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/CacheValidator.kt @@ -0,0 +1,56 @@ +package ru.fromchat.core.cache + +import ru.fromchat.api.Message +import ru.fromchat.api.db.GENERAL_PUBLIC_GROUP_ID +import ru.fromchat.api.db.conversationIdForDm +import ru.fromchat.api.db.conversationIdForGroup +import ru.fromchat.api.db.dmOtherUserIdFromConversationId +import ru.fromchat.api.db.groupIdFromConversationId + +/** + * In-partition validation so stale rows from a wrong server switch cannot render as trusted history. + */ +object CacheValidator { + fun filterMessages( + conversationId: String, + messages: List, + currentUserId: Int?, + dmPeerUserId: Int? = dmOtherUserIdFromConversationId(conversationId), + ): List = messages.filter { isMessageValid(it, conversationId, currentUserId, dmPeerUserId) } + + fun isMessageValid( + message: Message, + conversationId: String, + currentUserId: Int?, + dmPeerUserId: Int? = dmOtherUserIdFromConversationId(conversationId), + ): Boolean { + val groupId = groupIdFromConversationId(conversationId) + if (groupId != null) { + if (groupId != GENERAL_PUBLIC_GROUP_ID) return false + return message.user_id > 0 + } + val peer = dmPeerUserId ?: return false + val self = currentUserId ?: return false + if (message.user_id != self && message.user_id != peer) return false + if (message.id < 0 && message.client_message_id.isNullOrBlank()) return false + return true + } + + /** + * Returns false when more than half of rows fail validation — caller should purge and refetch. + */ + fun isConversationCacheCoherent( + conversationId: String, + messages: List, + currentUserId: Int?, + ): Boolean { + if (messages.isEmpty()) return true + val dmPeer = dmOtherUserIdFromConversationId(conversationId) + val valid = filterMessages(conversationId, messages, currentUserId, dmPeer) + return valid.size * 2 >= messages.size + } + + fun conversationIdForDmPeer(otherUserId: Int): String = conversationIdForDm(otherUserId) + + fun conversationIdForPublic(): String = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.kt new file mode 100644 index 0000000..8cb80be --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.kt @@ -0,0 +1,4 @@ +package ru.fromchat.core.cache + +/** Deletes the `cacheDir/fromchat/` tree (blobs, DB file on disk). Call after [ru.fromchat.api.db.MessageRepository.clearAllCache]. */ +expect suspend fun wipeFromChatCacheDirectory() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.kt new file mode 100644 index 0000000..b74588f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.kt @@ -0,0 +1,8 @@ +package ru.fromchat.core.cache + +/** + * Detects OS "clear cache" (generation sentinel missing) and records a new generation after open. + */ +expect suspend fun ensureFromChatCacheGeneration() + +expect suspend fun writeFromChatCacheGeneration() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/OutboundFileUnavailableException.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/OutboundFileUnavailableException.kt new file mode 100644 index 0000000..02760be --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/OutboundFileUnavailableException.kt @@ -0,0 +1,16 @@ +package ru.fromchat.core.cache + +/** Original attachment URI is no longer readable (revoked permission, deleted file, etc.). */ +class OutboundFileUnavailableException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) + +fun Throwable.isOutboundFileUnavailable(): Boolean { + var current: Throwable? = this + while (current != null) { + if (current is OutboundFileUnavailableException) return true + current = current.cause + } + return false +} 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 new file mode 100644 index 0000000..469cd84 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.kt @@ -0,0 +1,31 @@ +package ru.fromchat.core.cache + +data class StagedOutboundFile( + val uri: String, + val sizeBytes: Long, +) + +/** + * Copies the picked attachment into instance-scoped cache so uploads survive process death. + */ +expect suspend fun stageOutboundFileForUpload( + instanceId: String, + clientMessageId: String, + sourceUri: String, +): StagedOutboundFile + +/** Reads the picked attachment from a platform URI string. */ +expect suspend fun readOutboundFileBytes(fileUri: String): ByteArray + +expect suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray) + +expect suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? + +expect suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String) + +expect suspend fun loadUploadTransportCipherJson(instanceId: String, clientMessageId: String): String? + +expect suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String) + +/** Drops upload secrets and staging copy; keeps [DecryptedImageCache] files intact. */ +expect suspend fun clearUploadSecretsOnly(instanceId: String, clientMessageId: String) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt index d1aea2d..f8b6e82 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt @@ -28,10 +28,10 @@ object Config { } /** - * Update server configuration + * Update server configuration (persists to storage before updating in-memory state). */ - fun updateServerConfig(config: ServerConfigData) { - Settings.serverConfig = config + suspend fun updateServerConfig(config: ServerConfigData) { + Settings.setServerConfig(config) _serverConfig.value = config } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdGuard.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdGuard.kt new file mode 100644 index 0000000..f4d60bf --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdGuard.kt @@ -0,0 +1,43 @@ +package ru.fromchat.core.instance + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlin.concurrent.Volatile +import ru.fromchat.api.db.InstanceRegistryStore +import ru.fromchat.core.ServerConfigData +import ru.fromchat.core.cache.CacheContext + +/** + * Handles [INSTANCE_ID_HEADER] on API responses (main client + probe during server setup). + */ +object InstanceIdGuard { + const val INSTANCE_ID_HEADER = "X-FromChat-Instance-Id" + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @Volatile + var probeConfig: ServerConfigData? = null + + fun onResponseHeader(headerValue: String?, config: ServerConfigData? = null) { + val raw = headerValue?.trim().orEmpty() + if (raw.isEmpty() || !isValidInstanceUuid(raw)) return + val cfg = config ?: probeConfig ?: return + scope.launch { + val bound = InstanceRegistryStore.getActiveInstanceIdForConfig(cfg)?.trim().orEmpty() + when { + bound.isEmpty() -> InstanceRegistryStore.rebindServerInstance(cfg, raw) + !bound.equals(raw, ignoreCase = true) -> + InstanceRegistryStore.rebindServerInstanceOnMismatch(cfg, bound, raw) + else -> InstanceRegistryStore.registerInstanceEncountered(raw) + } + if (probeConfig == null && CacheContext.activeInstanceId.value.isNotEmpty()) { + val userId = CacheContext.activeUserId.value + if (!bound.equals(raw, ignoreCase = true)) { + CacheContext.setActiveInstance(raw, userId) + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdResolve.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdResolve.kt new file mode 100644 index 0000000..54d5ed8 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/InstanceIdResolve.kt @@ -0,0 +1,112 @@ +package ru.fromchat.core.instance + +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.HttpRequestTimeoutException +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import ru.fromchat.api.ApiClient +import ru.fromchat.api.db.InstanceRegistryStore +import ru.fromchat.core.ServerConfigData +import ru.fromchat.core.configKey + +private val UUID_REGEX = + Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + +fun isValidInstanceUuid(value: String): Boolean = UUID_REGEX.matches(value.trim()) + +sealed interface InstanceIdResolveResult { + data class Cached(val instanceId: String) : InstanceIdResolveResult + data class Fetched(val instanceId: String) : InstanceIdResolveResult + data class InstanceIdChanged(val oldId: String, val newId: String) : InstanceIdResolveResult + data object Unsupported : InstanceIdResolveResult + data object Timeout : InstanceIdResolveResult + data object Unreachable : InstanceIdResolveResult +} + +private const val INSTANCE_FETCH_MS = 10_000L + +/** + * Resolves instance id for [config]: reads DB binding, optionally fetches `/instance_id`. + * On mismatch with cached binding, rebinds via [InstanceRegistryStore] (no partition delete). + */ +suspend fun resolveInstanceId( + config: ServerConfigData, + apiBaseUrl: String, + forceNetwork: Boolean, +): InstanceIdResolveResult { + val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty() + if (!forceNetwork && cached.isNotEmpty() && isValidInstanceUuid(cached)) { + return InstanceIdResolveResult.Cached(cached) + } + + val fetchResult = runCatching { + withTimeout(INSTANCE_FETCH_MS) { + ApiClient.fetchServerInstanceId(apiBaseUrl) + } + } + if (fetchResult.isFailure) { + return networkFailureToResolveResult(fetchResult.exceptionOrNull(), cached) + } + val fetched = fetchResult.getOrThrow().trim() + + if (fetched.isEmpty() || !isValidInstanceUuid(fetched)) { + return InstanceIdResolveResult.Unsupported + } + + if (cached.isEmpty()) { + InstanceRegistryStore.rebindServerInstance(config, fetched) + return InstanceIdResolveResult.Fetched(fetched) + } + if (cached.equals(fetched, ignoreCase = true)) { + InstanceRegistryStore.registerInstanceEncountered(fetched) + return InstanceIdResolveResult.Fetched(fetched) + } + InstanceRegistryStore.rebindServerInstanceOnMismatch(config, cached, fetched) + return InstanceIdResolveResult.InstanceIdChanged(cached, fetched) +} + +fun apiBaseUrlFor(config: ServerConfigData): String { + val scheme = if (config.httpsEnabled) "https" else "http" + return "$scheme://${config.serverIp}:${config.apiPort}/api" +} + +private fun networkFailureToResolveResult( + e: Throwable?, + cached: String, +): InstanceIdResolveResult = when (e) { + is TimeoutCancellationException, + is HttpRequestTimeoutException, + is SocketTimeoutException, + -> { + if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { + InstanceIdResolveResult.Cached(cached) + } else { + InstanceIdResolveResult.Timeout + } + } + is ConnectTimeoutException -> { + if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { + InstanceIdResolveResult.Cached(cached) + } else { + InstanceIdResolveResult.Unreachable + } + } + is ClientRequestException -> { + if (e.response.status.value in 400..499) { + InstanceIdResolveResult.Unsupported + } else if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { + InstanceIdResolveResult.Cached(cached) + } else { + InstanceIdResolveResult.Unreachable + } + } + else -> { + if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { + InstanceIdResolveResult.Cached(cached) + } else { + InstanceIdResolveResult.Unreachable + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/ServerConfigProbe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/ServerConfigProbe.kt new file mode 100644 index 0000000..c6d8516 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/ServerConfigProbe.kt @@ -0,0 +1,101 @@ +package ru.fromchat.core.instance + +import kotlin.time.TimeSource +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import ru.fromchat.api.ApiClient +import ru.fromchat.api.WebSocketManager +import ru.fromchat.api.db.InstanceRegistryStore +import ru.fromchat.core.ServerConfigData +import ru.fromchat.core.cache.CacheContext +import ru.fromchat.core.config.Config + +sealed interface ServerProbeResult { + data class Supported( + val instanceId: String, + val callsOk: Boolean, + val pingMs: Int, + ) : ServerProbeResult + + data object Unsupported : ServerProbeResult + data object Timeout : ServerProbeResult + data object Unreachable : ServerProbeResult +} + +private const val CALLS_PROBE_MS = 1_500L + +suspend fun probeCallsReachable(config: ServerConfigData): Boolean { + val urlScheme = if (config.httpsEnabled) "https" else "http" + val host = config.serverIp.trim() + val authorityHost = host.removePrefix("[").removeSuffix("]").ifEmpty { host } + val root = "$urlScheme://$authorityHost:${config.callsPort}/" + return runCatching { + withTimeout(CALLS_PROBE_MS) { + ApiClient.probeHttpGet(root) + } + }.getOrDefault(false) +} + +suspend fun probeServer(config: ServerConfigData): ServerProbeResult { + val apiBase = apiBaseUrlFor(config) + val mark = TimeSource.Monotonic.markNow() + InstanceIdGuard.probeConfig = config + try { + val resolve = resolveInstanceId(config, apiBase, forceNetwork = true) + val pingMs = mark.elapsedNow().inWholeMilliseconds.toInt().coerceAtLeast(0) + val instanceId = when (resolve) { + is InstanceIdResolveResult.Cached -> resolve.instanceId + is InstanceIdResolveResult.Fetched -> resolve.instanceId + is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId + InstanceIdResolveResult.Unsupported -> return ServerProbeResult.Unsupported + InstanceIdResolveResult.Timeout -> return ServerProbeResult.Timeout + InstanceIdResolveResult.Unreachable -> return ServerProbeResult.Unreachable + } + val callsOk = probeCallsReachable(config) + return ServerProbeResult.Supported(instanceId, callsOk, pingMs) + } finally { + InstanceIdGuard.probeConfig = null + } +} + +suspend fun applyServerConfig( + config: ServerConfigData, + instanceId: String, + callsOk: Boolean, +) { + val tentative = config.copy(callsEnabled = callsOk) + Config.updateServerConfig(tentative) + val userId = ApiClient.user?.id + InstanceRegistryStore.rebindServerInstance(tentative, instanceId) + CacheContext.setActiveInstance(instanceId, userId) +} + +suspend fun applyServerAndNavigate( + probe: ServerProbeResult.Supported, + config: ServerConfigData, + bearer: String, + onNavigateLogin: suspend () -> Unit, + onNavigateChat: suspend () -> Unit, + onLogoutOldHost: suspend () -> Unit, +) { + val apiBase = apiBaseUrlFor(config) + val token = bearer.trim() + if (token.isEmpty()) { + applyServerConfig(config, probe.instanceId, probe.callsOk) + WebSocketManager.disconnect() + onNavigateLogin() + return + } + val authOk = ApiClient.checkAuthAt(apiBase, token) + if (!authOk) { + onLogoutOldHost() + applyServerConfig(config, probe.instanceId, probe.callsOk) + WebSocketManager.disconnect() + onNavigateLogin() + return + } + applyServerConfig(config, probe.instanceId, probe.callsOk) + WebSocketManager.disconnect() + WebSocketManager.connect(forceRestart = true) + onNavigateChat() +} 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 new file mode 100644 index 0000000..4827127 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/instance/SessionInstanceBootstrap.kt @@ -0,0 +1,62 @@ +package ru.fromchat.core.instance + +import ru.fromchat.api.ApiClient +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 + data object OfflineCached : SessionBootstrapResult + data object LogoutRequired : SessionBootstrapResult +} + +/** + * Ensures [CacheContext] has an active instance for the current server config when a session exists. + */ +suspend fun bootstrapSessionInstance(hasToken: Boolean): SessionBootstrapResult { + if (!hasToken) return SessionBootstrapResult.Ready + val config = Settings.serverConfig + val apiBase = apiBaseUrlFor(config) + val resolve = resolveInstanceId( + config = config, + apiBaseUrl = apiBase, + forceNetwork = true, + ) + return when (resolve) { + 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 + } + CacheContext.setActiveInstance(id, ApiClient.user?.id) + scheduleOutboxProcessing(id) + SessionBootstrapResult.Ready + } + InstanceIdResolveResult.Timeout, + InstanceIdResolveResult.Unreachable, + -> { + val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty() + if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { + CacheContext.setActiveInstance(cached, ApiClient.user?.id) + scheduleOutboxProcessing(cached) + SessionBootstrapResult.OfflineCached + } else { + SessionBootstrapResult.OfflineCached + } + } + InstanceIdResolveResult.Unsupported -> SessionBootstrapResult.LogoutRequired + } +} + +suspend fun logoutIfInstanceUnsupported() { + runCatching { ApiClient.logout() } + ApiClient.clearMemorySession() + CacheContext.clearActive() +} 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 16898bc..963a8b5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/DmCrypto.kt @@ -57,7 +57,9 @@ suspend fun decryptEnvelope(envelope: DmEnvelope, currentUserId: Int?): String { suspend fun decryptFile( file: ru.fromchat.api.DmFile, envelope: DmEnvelope, - currentUserId: Int? + currentUserId: Int?, + downloadResumeKey: String? = null, + onDownloadProgress: ((Int) -> Unit)? = null, ): ByteArray { val wrappedMekB64 = file.wrappedMekB64 ?: envelope.files?.find { it.path == file.path }?.wrappedMekB64 @@ -68,7 +70,18 @@ suspend fun decryptFile( ?: throw IllegalArgumentException("No nonce available for file decryption: ${file.path}") val mek = unwrapMek(wrappedMekB64, envelope, currentUserId) - val encryptedBytes = ru.fromchat.api.ApiClient.fetchEncryptedFile(file.path) - val ciphertextB64 = com.pr0gramm3r101.utils.crypto.Base64.encode(encryptedBytes) - return DmCrypto.decryptEnvelope(nonceB64, ciphertextB64, mek) + 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()) { + throw IllegalArgumentException("Encrypted file is empty: ${file.path}") + } + return DmCrypto.decryptAesGcm(nonceB64, encryptedBytes, mek) } 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 8fc085a..e628254 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 @@ -28,4 +28,7 @@ expect object DmCrypto { * @return Decrypted plaintext */ suspend fun decryptEnvelope(ivB64: String, ciphertextB64: String, mek: ByteArray): ByteArray + + /** AES-GCM decrypt downloaded file bytes (ciphertext + tag; IV from [ivB64]). */ + suspend fun decryptAesGcm(ivB64: String, ciphertext: ByteArray, mek: ByteArray): ByteArray } 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 1fcbbbf..e2d70f2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -58,7 +58,11 @@ import ru.fromchat.api.UserStatusStore import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketUpdatesData +import ru.fromchat.api.outbox.OutgoingMessageCoordinator +import ru.fromchat.api.outbox.scheduleOutboxProcessing +import ru.fromchat.core.cache.CacheContext import ru.fromchat.core.Logger +import ru.fromchat.core.cache.ensureFromChatCacheGeneration import ru.fromchat.core.config.Config import ru.fromchat.net.NetworkConnectivity import ru.fromchat.ui.auth.LoginScreen @@ -191,11 +195,27 @@ fun App( Config.initialize() } + runCatching { ensureFromChatCacheGeneration() } + runCatching { NetworkConnectivity.ensureStarted() } // Load persisted token and user data ApiClient.loadPersistedData() + val hasTokenForBootstrap = ApiClient.token?.isNotEmpty() == true + if (hasTokenForBootstrap) { + when (ru.fromchat.core.instance.bootstrapSessionInstance(hasToken = true)) { + ru.fromchat.core.instance.SessionBootstrapResult.LogoutRequired -> { + ru.fromchat.core.instance.logoutIfInstanceUnsupported() + startDestination = "login" + return@LaunchedEffect + } + ru.fromchat.core.instance.SessionBootstrapResult.OfflineCached, + ru.fromchat.core.instance.SessionBootstrapResult.Ready, + -> Unit + } + } + runCatching { ProfileCache.hydrateFromDisk() } val hasTokenInitially = ApiClient.token?.isNotEmpty() == true @@ -235,6 +255,15 @@ fun App( Lifecycle.Event.ON_START -> { AppForeground.setForeground(true) WebSocketManager.connect() + MainScope().launch { + val instanceId = CacheContext.activeInstanceId.value.trim() + if (instanceId.isNotEmpty()) { + scheduleOutboxProcessing(instanceId) + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { + OutgoingMessageCoordinator.drainOutboxForInstance(instanceId) + } + } + } } Lifecycle.Event.ON_STOP -> AppForeground.setForeground(false) else -> {} 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 new file mode 100644 index 0000000..c7922ea --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadScheduler.kt @@ -0,0 +1,108 @@ +package ru.fromchat.ui.chat + +import kotlinx.coroutines.CompletableDeferred +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 + +/** + * Limits concurrent DM attachment decrypt/download work to [MAX_PARALLEL]. + * Additional requests wait in a priority queue (visible messages first). + */ +object AttachmentDownloadScheduler { + private const val MAX_PARALLEL = 2 + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + + private data class Pending( + val storageKey: String, + val messageId: Int, + val enqueuedAt: Long, + val work: suspend () -> String?, + val result: CompletableDeferred, + ) + + private val waiting = mutableListOf() + private val keyToDeferred = mutableMapOf>() + private var activeCount = 0 + + /** + * Runs [work] when a download slot is available. Duplicate [storageKey] shares one result. + */ + suspend fun run( + storageKey: String, + messageId: Int, + work: suspend () -> String?, + ): String? { + 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 + } + } + pumpLocked() + return deferred.await() + } + + fun reprioritize() { + scope.launch { + mutex.withLock { + sortWaitingLocked() + } + pumpLocked() + } + } + + private suspend fun pumpLocked() { + val toStart = mutex.withLock { + val jobs = mutableListOf() + while (activeCount < MAX_PARALLEL && waiting.isNotEmpty()) { + val next = waiting.removeAt(0) + activeCount++ + jobs.add(next) + } + jobs + } + for (pending in toStart) { + scope.launch { + runPending(pending) + } + } + } + + private suspend fun runPending(pending: Pending) { + val outcome = runCatching { pending.work() } + mutex.withLock { + activeCount = (activeCount - 1).coerceAtLeast(0) + keyToDeferred.remove(pending.storageKey) + } + outcome.fold( + onSuccess = { pending.result.complete(it) }, + onFailure = { pending.result.completeExceptionally(it) }, + ) + pumpLocked() + } + + private fun sortWaitingLocked() { + waiting.sortWith( + compareBy { pending -> + if (AttachmentDownloadVisibility.isPrioritized(pending.messageId)) 0 else 1 + }.thenBy { it.enqueuedAt }, + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadVisibility.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadVisibility.kt new file mode 100644 index 0000000..2c1ffa5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentDownloadVisibility.kt @@ -0,0 +1,40 @@ +package ru.fromchat.ui.chat + +import androidx.compose.foundation.lazy.LazyListState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import ru.fromchat.api.Message + +/** Visible chat message ids (from [LazyListState]); used to prioritize attachment downloads. */ +object AttachmentDownloadVisibility { + private val _visibleMessageIds = MutableStateFlow>(emptySet()) + + val visibleMessageIds: StateFlow> = _visibleMessageIds.asStateFlow() + + fun setVisibleMessageIds(ids: Set) { + if (_visibleMessageIds.value == ids) return + _visibleMessageIds.value = ids + AttachmentDownloadScheduler.reprioritize() + } + + fun isPrioritized(messageId: Int): Boolean = + messageId != 0 && messageId in _visibleMessageIds.value +} + +/** Maps reverse-layout chat list indices to message ids (index 0 = bottom spacer). */ +fun visibleMessageIdsInChatList( + listState: LazyListState, + messages: List, + extraMessageId: Int? = null, +): Set { + val reversed = messages.asReversed() + val ids = listState.layoutInfo.visibleItemsInfo.mapNotNull { info -> + val lazyIndex = info.index + if (lazyIndex <= 0) null else reversed.getOrNull(lazyIndex - 1)?.id + }.toMutableSet() + if (extraMessageId != null && extraMessageId != 0) { + ids.add(extraMessageId) + } + return ids +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentImageGeometry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentImageGeometry.kt new file mode 100644 index 0000000..6d67751 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentImageGeometry.kt @@ -0,0 +1,63 @@ +package ru.fromchat.ui.chat + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** Bubble top radius (must match [ru.fromchat.ui.chat.MessageItem] bubble shape). */ +private val BUBBLE_TOP = 20.dp + +/** Padding between bubble edge and attachment image (must match MessageItem image padding). */ +internal val ATTACHMENT_IMAGE_INSET = 2.dp + +/** Slight rounding on image preview bottom corners (less than bubble). */ +private val IMAGE_BOTTOM_CORNER = 4.dp + +/** Inner clip: top corners follow bubble minus inset; bottom corners lightly rounded. */ +@Suppress("UNUSED_PARAMETER") +internal fun attachmentImageCornerShape(isAuthor: Boolean): RoundedCornerShape { + val inset = ATTACHMENT_IMAGE_INSET + return RoundedCornerShape( + topStart = (BUBBLE_TOP - inset).coerceAtLeast(0.dp), + topEnd = (BUBBLE_TOP - inset).coerceAtLeast(0.dp), + bottomStart = IMAGE_BOTTOM_CORNER, + bottomEnd = IMAGE_BOTTOM_CORNER, + ) +} + +/** Width / height for layout and decode. Pixel dimensions and server ratios beat stale pending. */ +internal fun imageAspectRatioForMessage( + fileAspectRatios: List?, + fileDimensions: List>?, + pendingFileAspectRatio: Float?, + fileIndex: Int = 0, + @Suppress("UNUSED_PARAMETER") confirmed: Boolean = true, + @Suppress("UNUSED_PARAMETER") hasLocalPreview: Boolean = false, +): Float? { + fileDimensions?.getOrNull(fileIndex)?.let { (w, h) -> + if (w > 0 && h > 0) { + return ru.fromchat.api.db.aspectRatioFromDimensionPair(w, h) + } + } + fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }?.let { return it } + return pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f } +} + +internal fun coalesceDecodeTarget(vararg sizes: ChatPreviewDecodeSize?): ChatPreviewDecodeSize { + val present = sizes.filterNotNull() + require(present.isNotEmpty()) + return ChatPreviewDecodeSize( + widthPx = present.maxOf { it.widthPx }, + heightPx = present.maxOf { it.heightPx }, + ) +} + +internal fun decodeSizeChangedMeaningfully( + previous: ChatPreviewDecodeSize?, + measured: ChatPreviewDecodeSize, +): Boolean { + if (previous == null) return true + val wRatio = measured.widthPx.toFloat() / previous.widthPx.toFloat() + val hRatio = measured.heightPx.toFloat() / previous.heightPx.toFloat() + return wRatio > 1.12f || hRatio > 1.12f || wRatio < 0.88f || hRatio < 0.88f +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMediaLog.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMediaLog.kt new file mode 100644 index 0000000..c29371d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentMediaLog.kt @@ -0,0 +1,39 @@ +package ru.fromchat.ui.chat + +import kotlin.time.Clock +import ru.fromchat.core.Logger + +/** + * Unified filter tag for attachment media pipeline (upload, download, disk/bitmap cache, tile decode). + * Logcat: `adb logcat -s AttachmentMedia` or filter `AttachmentMedia` in Android Studio. + */ +object AttachmentMediaLog { + const val TAG = "AttachmentMedia" + + fun upload(message: String, vararg fields: Pair) = log("UPLOAD", message, fields) + + fun download(message: String, vararg fields: Pair) = log("DOWNLOAD", message, fields) + + fun diskCache(message: String, vararg fields: Pair) = log("DISK", message, fields) + + fun bitmapCache(message: String, vararg fields: Pair) = log("BITMAP", message, fields) + + fun tileLoad(message: String, vararg fields: Pair) = log("TILE", message, fields) + + fun persist(message: String, vararg fields: Pair) = log("PERSIST", message, fields) + + fun nowMs(): Long = Clock.System.now().toEpochMilliseconds() + + /** Short message text for download/upload log lines (not for UI). */ + fun messageLabel(content: String?, maxLen: Int = 64): String? = + content?.trim()?.take(maxLen)?.takeIf { it.isNotEmpty() } + + private fun log(subsystem: String, message: String, fields: Array>) { + val suffix = if (fields.isEmpty()) { + "" + } else { + " | " + fields.joinToString(" ") { (k, v) -> "$k=$v" } + } + Logger.d(TAG, "[$subsystem] $message$suffix") + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt index 48cf4f9..d1209e4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt @@ -31,3 +31,6 @@ expect fun getFilenameFromUri(uri: String): String /** Get image aspect ratio (width/height) from URI without loading full image. Returns null if unavailable. */ expect suspend fun getImageAspectRatio(uri: String): Float? + +/** Pixel width/height after EXIF orientation, or null if unavailable. */ +expect suspend fun getImageDimensions(uri: String): Pair? 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 9ce15bc..ccd2994 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 @@ -42,19 +42,31 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.material3.TextButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage @@ -64,11 +76,18 @@ import com.pr0gramm3r101.utils.crypto.Base64 import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.api.ApiClient +import ru.fromchat.api.AttachmentDownloadNotifier 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.cd_attachment_retry private val IMAGE_SIZE = 160.dp -private val IMAGE_RADIUS = 12.dp +private const val BLUR_FADE_MS = 450 internal fun isImageFilename(name: String): Boolean = name.endsWith(".png", true) || name.endsWith(".jpg", true) || @@ -84,6 +103,8 @@ fun AttachmentPreview( /** Filename for pending (non-image) files; used when pendingFileUri is set. */ pendingFilename: String? = null, isUploading: Boolean, + /** Waiting for server ack after upload finished (keep blur, no progress ring). */ + awaitingServerAck: Boolean = false, /** 0–100 upload progress when isUploading; null = indefinite */ uploadProgress: Int? = null, fileThumbnail: String? = null, @@ -91,11 +112,14 @@ fun AttachmentPreview( fileSizeBytes: Long? = null, messageId: Int? = null, fileIndex: Int? = null, + clientMessageId: String? = null, onFileClick: (() -> Unit)? = null, onImageClick: (() -> Unit)? = null, onImageBounds: ((Rect) -> Unit)? = null, isExpanded: Boolean = false, isAuthor: Boolean = false, + /** Message text shown in attachment download/upload logs. */ + messageLabel: String? = null, modifier: Modifier = Modifier ) { val isImage = when { @@ -108,8 +132,9 @@ fun AttachmentPreview( else -> false } - val isImageWithThumb = file != null && isImage && dmEnvelope != null && !fileThumbnail.isNullOrBlank() - val isPendingImage = pendingFileUri != null && isImage + val isPendingImage = pendingFileUri != null && isImage && file == null && dmEnvelope == null + val isConfirmedImage = file != null && isImage && dmEnvelope != null && !isPendingImage + val showImageTile = isPendingImage || isConfirmedImage val isPendingFile = pendingFileUri != null && !isImage when { @@ -124,20 +149,29 @@ fun AttachmentPreview( sizeBytes = fileSizeBytes, onClick = if (file != null) onFileClick else null, isAuthor = isAuthor, - isUploading = isPendingFile && isUploading, + isUploading = isPendingFile && (isUploading || awaitingServerAck), uploadProgress = if (isPendingFile) uploadProgress else null, modifier = modifier ) } - isImageWithThumb || isPendingImage -> { + showImageTile -> { var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) { mutableStateOf(false) } Box( modifier = modifier .then( - if (onImageClick != null && isFullyLoaded && !isExpanded && (isImageWithThumb || !isPendingImage)) Modifier.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onImageClick) - else Modifier + if (onImageClick != null && !isExpanded && + (isPendingImage || isFullyLoaded || pendingFileUri != null) + ) { + Modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onImageClick, + ) + } else { + Modifier + } ) .conditional( fileAspectRatio != null && fileAspectRatio > 0f, @@ -150,9 +184,9 @@ fun AttachmentPreview( Modifier.size(IMAGE_SIZE) } ) - .clip(RoundedCornerShape(IMAGE_RADIUS)) + .clip(attachmentImageCornerShape(isAuthor)) .then( - if (onImageBounds != null && (isImageWithThumb || !isPendingImage)) { + if (onImageBounds != null && showImageTile) { Modifier.onGloballyPositioned { coords -> val pos = coords.positionInRoot() val size = coords.size @@ -176,27 +210,26 @@ fun AttachmentPreview( .fillMaxSize() .graphicsLayer { alpha = if (isExpanded) 0f else 1f } ) { - when { - isPendingImage -> UnifiedImageContent( - localUri = pendingFileUri, - messageId = messageId, - fileIndex = fileIndex, - file = file, - envelope = dmEnvelope, - currentUserId = currentUserId, - isUploading = isUploading, - uploadProgress = uploadProgress, - onFullyLoaded = { if (it) isFullyLoaded = true } - ) - else -> DecryptedImageContent( + val imageStableKey = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } + ?: "img:${messageId ?: 0}:${fileIndex ?: 0}" + key(imageStableKey) { + ChatImageTileContent( messageId = messageId ?: -1, fileIndex = fileIndex ?: 0, - file = file!!, - envelope = dmEnvelope!!, - currentUserId = currentUserId, - thumbnailBase64 = fileThumbnail!!, + clientMessageId = clientMessageId, + localUri = pendingFileUri, + serverFile = file, + envelope = dmEnvelope, + thumbnailBase64 = fileThumbnail, aspectRatio = fileAspectRatio, - onFullyLoaded = { if (it) isFullyLoaded = true } + currentUserId = currentUserId, + isAuthor = isAuthor, + isOutboundPending = isPendingImage, + isUploading = isUploading, + awaitingServerAck = awaitingServerAck, + uploadProgress = uploadProgress, + messageLabel = messageLabel, + onFullyLoaded = { if (it) isFullyLoaded = true }, ) } } @@ -207,104 +240,438 @@ fun AttachmentPreview( @OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun UnifiedImageContent( - localUri: String, - messageId: Int?, - fileIndex: Int?, - file: DmFile?, +private fun ChatImageTileContent( + messageId: Int, + fileIndex: Int, + clientMessageId: String?, + localUri: String?, + serverFile: DmFile?, envelope: DmEnvelope?, + thumbnailBase64: String?, + aspectRatio: Float?, currentUserId: Int?, + isAuthor: Boolean, + isOutboundPending: Boolean, isUploading: Boolean, + awaitingServerAck: Boolean, uploadProgress: Int?, - onFullyLoaded: (Boolean) -> Unit = {} + messageLabel: String? = null, + onFullyLoaded: (Boolean) -> Unit = {}, ) { - var cachedPath by remember(messageId, fileIndex, file?.path) { - mutableStateOf( - if (messageId != null && fileIndex != null && file != null) { - DecryptedImageCache.getCached(messageId, fileIndex, file.path) - } else { - null - } - ) + val clipShape = attachmentImageCornerShape(isAuthor) + val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } + val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f } + val fallbackDecodeSize = rememberChatPreviewDecodeSize(IMAGE_SIZE, layoutAspect) + val seedDecodeSize = remember(layoutAspect) { previewSeedDecodeSize(layoutAspect) } + val decryptCacheKey = remember(messageId, fileIndex, cacheClientId) { + DecryptedImageCache.storageKey(messageId, fileIndex, cacheClientId) + } + var tileDecodeSize by remember(decryptCacheKey) { mutableStateOf(null) } + val decodeSize = remember(tileDecodeSize, fallbackDecodeSize, seedDecodeSize) { + coalesceDecodeTarget(tileDecodeSize, fallbackDecodeSize, seedDecodeSize) } - LaunchedEffect(messageId, fileIndex, file?.path, envelope) { - cachedPath = if (messageId != null && fileIndex != null && file != null && envelope != null) { - DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId) + val initialFull = remember(decryptCacheKey) { LocalDecodedImageCache.peekFull(decryptCacheKey) } + val hadInstantFull = initialFull != null + val hasLocalSource = !localUri.isNullOrBlank() + val hasServerThumb = thumbnailBase64?.isNotBlank() == true + + var fullBitmap by remember(decryptCacheKey) { + mutableStateOf(initialFull) + } + var didRevealFull by remember(decryptCacheKey) { mutableStateOf(hadInstantFull) } + val thumbBlurAlpha = remember(decryptCacheKey, hasServerThumb, hasLocalSource) { + Animatable( + when { + hadInstantFull -> 0f + hasServerThumb || hasLocalSource -> 1f + else -> 0f + }, + ) + } + val fullRevealAlpha = remember(decryptCacheKey) { + Animatable(if (hadInstantFull) 1f else 0f) + } + val showOutboundBlurOverlay = isOutboundPending && hasLocalSource + val outboundOverlayAlpha = remember(showOutboundBlurOverlay) { + Animatable(if (showOutboundBlurOverlay) 1f else 0f) + } + val downloadProgressByKey by AttachmentDownloadNotifier.progressPercentByKey.collectAsState() + val downloadProgress = remember(downloadProgressByKey, messageId, fileIndex, cacheClientId) { + DecryptedImageCache.resolveDownloadPercent( + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = cacheClientId, + progressByKey = downloadProgressByKey, + ) + } + val decryptFailed = remember(downloadProgressByKey, messageId, fileIndex, cacheClientId) { + AttachmentDownloadNotifier.isFailed(messageId, fileIndex, cacheClientId) + } + var isAwaitingNetworkFull by remember(decryptCacheKey) { mutableStateOf(false) } + var loadAttempt by remember(decryptCacheKey) { mutableIntStateOf(0) } + LaunchedEffect(showOutboundBlurOverlay) { + if (showOutboundBlurOverlay) { + outboundOverlayAlpha.snapTo(1f) } else { - null + if (fullBitmap != null) { + didRevealFull = true + fullRevealAlpha.snapTo(1f) + thumbBlurAlpha.snapTo(0f) + } + outboundOverlayAlpha.animateTo(0f, tween(BLUR_FADE_MS, easing = FastOutSlowInEasing)) } } - val localPainter = rememberAsyncImagePainter( - model = localUri, - contentScale = ContentScale.Crop - ) - val localState by localPainter.state.collectAsState() - LaunchedEffect(localState) { - if (localState is coil3.compose.AsyncImagePainter.State.Success) { + var cachedPath by remember(decryptCacheKey) { + mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)) + } + val thumbnailBytes = remember(thumbnailBase64) { + thumbnailBase64?.let { decodeAttachmentThumbnailBase64(it) } + } + val thumbBitmap by produceState( + initialValue = LocalDecodedImageCache.peekThumb(decryptCacheKey), + decryptCacheKey, + thumbnailBytes, + decodeSize, + ) { + if (thumbnailBytes == null) { + value = null + return@produceState + } + value = LocalDecodedImageCache.peekThumb(decryptCacheKey) + ?: withContext(Dispatchers.Default) { + LocalDecodedImageCache.loadThumb(decryptCacheKey, thumbnailBytes, decodeSize) + } + } + var decryptFinished by remember(decryptCacheKey) { + mutableStateOf(fullBitmap != null) + } + val imageContentScale = ContentScale.Fit + val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f) + + LaunchedEffect( + decryptCacheKey, + localUri, + serverFile?.path, + messageId, + isOutboundPending, + cacheClientId, + loadAttempt, + ) { + val target = decodeSize + AttachmentMediaLog.tileLoad( + "load_start", + "key" to decryptCacheKey, + "msgId" to messageId, + "pending" to isOutboundPending, + "localUri" to (localUri?.take(48) ?: "null"), + "target" to "${target.widthPx}x${target.heightPx}", + ) + val diskUri = DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId) + val localPaths = buildList { + localUri?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) } + diskUri?.let { cached -> if (none { it == cached }) add(cached) } + } + if (localPaths.isNotEmpty()) { + val loaded = withContext(Dispatchers.Default) { + LocalDecodedImageCache.peekFull(decryptCacheKey) + ?: localPaths.firstNotNullOfOrNull { path -> + LocalDecodedImageCache.loadFull( + decryptCacheKey, + path.removePrefix("file://"), + target, + ) + } + } + if (loaded != null) { + AttachmentMediaLog.tileLoad( + "load_local_ok", + "key" to decryptCacheKey, + "source" to "disk_or_pending", + "bmp" to "${loaded.width}x${loaded.height}", + ) + fullBitmap = loaded + cachedPath = diskUri ?: localPaths.firstOrNull() + decryptFinished = true + if (thumbBitmap == null) { + didRevealFull = true + fullRevealAlpha.snapTo(1f) + thumbBlurAlpha.snapTo(0f) + } + onFullyLoaded(true) + return@LaunchedEffect + } + AttachmentMediaLog.tileLoad( + "load_local_miss", + "key" to decryptCacheKey, + "paths" to localPaths.size, + ) + } + if (isOutboundPending) { + decryptFinished = localPaths.isEmpty() && thumbBitmap == null && thumbnailBytes == null + if (fullBitmap != null || thumbBitmap != null) onFullyLoaded(true) + return@LaunchedEffect + } + val file = serverFile + val env = envelope + if (file == null || env == null) { + decryptFinished = true + AttachmentMediaLog.tileLoad("load_skip_no_envelope", "key" to decryptCacheKey) + return@LaunchedEffect + } + if (diskUri != null) { + cachedPath = diskUri + val loaded = withContext(Dispatchers.Default) { + LocalDecodedImageCache.loadFull(decryptCacheKey, diskUri.removePrefix("file://"), target) + } + if (loaded != null) { + AttachmentMediaLog.tileLoad( + "load_disk_decode_ok", + "key" to decryptCacheKey, + "uri" to diskUri, + "bmp" to "${loaded.width}x${loaded.height}", + ) + fullBitmap = loaded + decryptFinished = true + if (thumbBitmap == null) { + didRevealFull = true + fullRevealAlpha.snapTo(1f) + thumbBlurAlpha.snapTo(0f) + } + onFullyLoaded(true) + return@LaunchedEffect + } + AttachmentMediaLog.tileLoad( + "load_disk_decode_failed", + "key" to decryptCacheKey, + "uri" to diskUri, + ) + } + AttachmentMediaLog.tileLoad( + "load_network_decrypt", + "key" to decryptCacheKey, + "file" to file.path, + ) + isAwaitingNetworkFull = true + val uri = try { + DecryptedImageCache.getOrDecrypt( + messageId = messageId, + fileIndex = fileIndex, + file = file, + envelope = env, + currentUserId = currentUserId, + clientMessageId = cacheClientId, + messageLabel = messageLabel, + ) + } catch (error: Throwable) { + AttachmentMediaLog.tileLoad( + "load_exception", + "key" to decryptCacheKey, + "err" to (error.message ?: error::class.simpleName), + "msg" to AttachmentMediaLog.messageLabel(messageLabel), + ) + null + } finally { + isAwaitingNetworkFull = false + } + cachedPath = uri + if (uri != null) { + fullBitmap = withContext(Dispatchers.Default) { + LocalDecodedImageCache.loadFull(decryptCacheKey, uri.removePrefix("file://"), target) + } + } + decryptFinished = true + AttachmentMediaLog.tileLoad( + if (fullBitmap != null) "load_done" else "load_failed", + "key" to decryptCacheKey, + "uri" to uri, + "msg" to AttachmentMediaLog.messageLabel(messageLabel), + ) + if (fullBitmap != null) { + AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) + if (thumbBitmap == null) { + didRevealFull = true + fullRevealAlpha.snapTo(1f) + thumbBlurAlpha.snapTo(0f) + } onFullyLoaded(true) } } + LaunchedEffect(decryptCacheKey, tileDecodeSize) { + val target = tileDecodeSize ?: return@LaunchedEffect + val current = fullBitmap ?: return@LaunchedEffect + if (!LocalDecodedImageCache.needsUpscale(current, target)) return@LaunchedEffect + val path = cachedPath?.removePrefix("file://") + ?: localUri?.removePrefix("file://") + ?: DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.removePrefix("file://") + ?: return@LaunchedEffect + AttachmentMediaLog.tileLoad( + "upscale_start", + "key" to decryptCacheKey, + "from" to "${current.width}x${current.height}", + "target" to "${target.widthPx}x${target.heightPx}", + ) + val upscaled = withContext(Dispatchers.Default) { + LocalDecodedImageCache.loadFull(decryptCacheKey, path, target) + } + if (upscaled != null) { + fullBitmap = upscaled + onFullyLoaded(true) + } + } + + LaunchedEffect(fullBitmap, thumbBitmap, hadInstantFull) { + when { + fullBitmap == null -> { + didRevealFull = false + fullRevealAlpha.snapTo(0f) + if (thumbBitmap != null) thumbBlurAlpha.snapTo(1f) + } + didRevealFull -> return@LaunchedEffect + hadInstantFull -> { + didRevealFull = true + fullRevealAlpha.snapTo(1f) + thumbBlurAlpha.snapTo(0f) + } + thumbBitmap != null -> { + didRevealFull = true + thumbBlurAlpha.snapTo(1f) + fullRevealAlpha.snapTo(0f) + coroutineScope { + launch { + thumbBlurAlpha.animateTo(0f, tween(BLUR_FADE_MS, easing = FastOutSlowInEasing)) + } + launch { + fullRevealAlpha.animateTo(1f, tween(BLUR_FADE_MS, easing = FastOutSlowInEasing)) + } + } + } + else -> { + didRevealFull = true + fullRevealAlpha.snapTo(1f) + thumbBlurAlpha.snapTo(0f) + } + } + } + + val isDownloadingFullImage = !isOutboundPending && fullBitmap == null && + (downloadProgress != null || isAwaitingNetworkFull) + val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay + val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !isOutboundPending + val showSpinnerOnly = fullBitmap == null && thumbBitmap == null && !hasLocalSource && + !showLoadFailedOverlay && + !showOutboundBlurOverlay && + (isDownloadingFullImage || (!decryptFinished && !isOutboundPending)) + Box( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(IMAGE_RADIUS)) - ) { - Image( - painter = localPainter, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - AnimatedContent( - targetState = isUploading, - modifier = Modifier.matchParentSize(), - transitionSpec = { - (fadeIn(animationSpec = tween(220)) + scaleIn(initialScale = 0.98f, animationSpec = tween(220))) - .togetherWith( - fadeOut(animationSpec = tween(450, easing = FastOutSlowInEasing)) + - scaleOut(targetScale = 1.02f, animationSpec = tween(450, easing = FastOutSlowInEasing)) - ) - }, - label = "upload_overlay" - ) { uploading -> - if (uploading) { - UploadingImageOverlay( - model = localUri, - uploadProgress = uploadProgress, - modifier = Modifier.matchParentSize() - ) - } else { - Box(modifier = Modifier.matchParentSize()) - } - } - if (cachedPath != null && file != null) { - val fullPainter = rememberAsyncImagePainter( - model = cachedPath!!, - contentScale = ContentScale.FillWidth - ) - val fullState by fullPainter.state.collectAsState() - when (fullState) { - is coil3.compose.AsyncImagePainter.State.Success -> { - LaunchedEffect(Unit) { onFullyLoaded(true) } - val alpha = remember { Animatable(0f) } - LaunchedEffect(Unit) { - alpha.animateTo(1f, animationSpec = tween(300)) + .onGloballyPositioned { coordinates -> + val size: IntSize = coordinates.size + if (size.width > 0 && size.height > 0) { + val measured = ChatPreviewDecodeSize(size.width, size.height) + if (decodeSizeChangedMeaningfully(tileDecodeSize, measured)) { + tileDecodeSize = measured } - Image( - painter = fullPainter, - contentDescription = file.name, - modifier = Modifier - .fillMaxSize() - .alpha(alpha.value), - contentScale = ContentScale.FillWidth + } + } + .clip(clipShape), + ) { + when { + else -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(tilePlaceholderColor), + ) + if (fullBitmap == null && hasLocalSource && !showOutboundBlurOverlay) { + AsyncImage( + model = localUri, + contentDescription = null, + contentScale = imageContentScale, + modifier = Modifier.fillMaxSize(), + ) + } + thumbBitmap?.let { thumb -> + Box( + modifier = Modifier + .fillMaxSize() + .then( + if (thumbBlurAlpha.value > 0.01f) { + Modifier.hazeEffect(style = HazeMaterials.thin()) + } else { + Modifier + }, + ), + ) { + CachedAttachmentImage( + bitmap = thumb, + contentDescription = serverFile?.name, + contentScale = imageContentScale, + modifier = Modifier + .fillMaxSize() + .alpha(thumbBlurAlpha.value.coerceIn(0f, 1f)), + ) + } + } + fullBitmap?.let { full -> + CachedAttachmentImage( + bitmap = full, + contentDescription = serverFile?.name, + contentScale = imageContentScale, + modifier = Modifier + .fillMaxSize() + .alpha(fullRevealAlpha.value.coerceIn(0f, 1f)), + ) + LaunchedEffect(full) { onFullyLoaded(true) } + } + if (showDownloadProgressOverlay) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + ExpressiveUploadIndicator( + uploadProgress = downloadProgress, + modifier = Modifier.size(48.dp), + ) + } + } else if (showSpinnerOnly) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + IndefiniteCircularProgress(modifier = Modifier.size(28.dp)) + } + } + if (showLoadFailedOverlay) { + AttachmentImageLoadFailedOverlay( + isAuthor = isAuthor, + onRetry = { + AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) + decryptFinished = false + ApiClient.clearPartialEncryptedDownload(decryptCacheKey) + loadAttempt++ + }, + modifier = Modifier.matchParentSize(), ) } - else -> Unit } } + if (showOutboundBlurOverlay && outboundOverlayAlpha.value > 0.01f) { + UploadingImageOverlay( + model = localUri!!, + uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null, + clipShape = clipShape, + contentScale = imageContentScale, + modifier = Modifier + .matchParentSize() + .alpha(outboundOverlayAlpha.value), + ) + } } } @@ -313,20 +680,22 @@ private fun UnifiedImageContent( private fun UploadingImageOverlay( model: String, uploadProgress: Int?, - modifier: Modifier = Modifier + clipShape: RoundedCornerShape, + contentScale: ContentScale = ContentScale.Fit, + modifier: Modifier = Modifier, ) { Box(modifier = modifier) { Box( modifier = Modifier .matchParentSize() - .clip(RoundedCornerShape(IMAGE_RADIUS)) + .clip(clipShape) .hazeEffect(style = HazeMaterials.thin()) ) { AsyncImage( model = model, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = contentScale, ) } Box( @@ -349,11 +718,16 @@ private fun ExpressiveUploadIndicator( uploadProgress: Int?, modifier: Modifier = Modifier, indicatorColor: Color? = null, - trackColorOverride: Color? = null + trackColorOverride: Color? = null, ) { - val clampedProgress = uploadProgress?.coerceIn(0, 100) - val indeterminate = clampedProgress == null || clampedProgress == 0 - val waveActive = clampedProgress != null && clampedProgress in 1..99 + // Latch first percent so we do not swap indeterminate ↔ determinate indicators (that resets animation). + var latchedPercent by remember { mutableStateOf(null) } + if (uploadProgress != null) { + latchedPercent = uploadProgress.coerceIn(0, 100) + } + val clampedProgress = latchedPercent + val indeterminate = clampedProgress == null + val waveActive = clampedProgress != null && clampedProgress < 100 val waveAnimSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing) @@ -406,7 +780,7 @@ private fun PendingImageContent( Box( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(IMAGE_RADIUS)) + .clip(attachmentImageCornerShape(isAuthor = false)) ) { AsyncImage( model = uri, @@ -430,7 +804,8 @@ private fun PendingImageContent( UploadingImageOverlay( model = uri, uploadProgress = uploadProgress, - modifier = Modifier.matchParentSize() + clipShape = attachmentImageCornerShape(isAuthor = false), + modifier = Modifier.matchParentSize(), ) } else { Box(modifier = Modifier.matchParentSize()) @@ -491,153 +866,88 @@ private fun DeterminateCircularProgress( ) } -@OptIn(ExperimentalHazeMaterialsApi::class) @Composable -private fun DecryptedImageContent( - messageId: Int, - fileIndex: Int, - file: DmFile, - envelope: DmEnvelope, - currentUserId: Int?, - thumbnailBase64: String, - aspectRatio: Float?, - onFullyLoaded: (Boolean) -> Unit = {} +internal fun CachedAttachmentImage( + bitmap: ImageBitmap, + contentDescription: String?, + contentScale: ContentScale, + modifier: Modifier = Modifier.fillMaxSize(), ) { - var cachedPath by remember(messageId, fileIndex, file.path) { - mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, file.path)) - } - val thumbnailBytes = remember(thumbnailBase64) { - runCatching { Base64.decode(thumbnailBase64) }.getOrNull() - } + Image( + bitmap = bitmap, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) +} - LaunchedEffect(messageId, fileIndex, file.path, envelope) { - cachedPath = DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId) - } +@Composable +internal fun FullscreenBitmapImage( + bitmap: ImageBitmap, + contentDescription: String?, + contentScale: ContentScale, + modifier: Modifier = Modifier.fillMaxSize(), +) = CachedAttachmentImage(bitmap, contentDescription, contentScale, modifier) +/** Supports raw base64 and `data:*;base64,` payloads from the server. */ +internal fun decodeAttachmentThumbnailBase64(value: String): ByteArray? { + val payload = value.trim().substringAfter("base64,", value).trim() + if (payload.isEmpty()) return null + return runCatching { Base64.decode(payload) }.getOrNull() +} + +@Composable +private fun AttachmentImageLoadFailedOverlay( + isAuthor: Boolean, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val failedText = stringResource(Res.string.attachment_image_load_failed) + val retryText = stringResource(Res.string.attachment_retry) + val retryCd = stringResource(Res.string.cd_attachment_retry) + val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface Box( - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(IMAGE_RADIUS)) + modifier = modifier + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center, ) { - Box(modifier = Modifier.fillMaxSize()) { - when { - cachedPath != null -> { - val fullPainter = rememberAsyncImagePainter( - model = cachedPath!!, - contentScale = ContentScale.FillWidth - ) - val fullState by fullPainter.state.collectAsState() - when (fullState) { - is coil3.compose.AsyncImagePainter.State.Success -> { - LaunchedEffect(Unit) { onFullyLoaded(true) } - Image( - painter = fullPainter, - contentDescription = file.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.FillWidth - ) - } - is coil3.compose.AsyncImagePainter.State.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) - } - } - else -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) - } - } - } - } - thumbnailBytes == null -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) - } - } - else -> { - val thumbPainter = rememberAsyncImagePainter( - model = thumbnailBytes, - contentScale = ContentScale.Crop - ) - val thumbState by thumbPainter.state.collectAsState() - when (thumbState) { - is coil3.compose.AsyncImagePainter.State.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) - } - } - is coil3.compose.AsyncImagePainter.State.Success -> { - LaunchedEffect(Unit) { onFullyLoaded(true) } - Box( - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(IMAGE_RADIUS)) - .hazeEffect(style = HazeMaterials.thin()) - ) { - Image( - painter = thumbPainter, - contentDescription = file.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } - if (cachedPath != null) { - val fullPainter = rememberAsyncImagePainter( - model = cachedPath!!, - contentScale = ContentScale.FillWidth - ) - val fullState by fullPainter.state.collectAsState() - when (fullState) { - is coil3.compose.AsyncImagePainter.State.Success -> { - LaunchedEffect(Unit) { onFullyLoaded(true) } - val alpha = remember { Animatable(0f) } - LaunchedEffect(Unit) { - alpha.animateTo(1f, animationSpec = tween(300)) - } - Image( - painter = fullPainter, - contentDescription = file.name, - modifier = Modifier - .fillMaxSize() - .alpha(alpha.value), - contentScale = ContentScale.FillWidth - ) - } - else -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) - } - } - } - } - } - else -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) - } - } - } + 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 CorruptedImagePlaceholder( + modifier: Modifier = Modifier, + clipShape: RoundedCornerShape = attachmentImageCornerShape(isAuthor = false), +) { + Box( + modifier = modifier + .clip(clipShape) + .hazeEffect(style = HazeMaterials.thin()), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.AttachFile, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } 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 e6543ae..7989ec7 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 @@ -8,9 +8,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable +import ru.fromchat.api.ApiClient import ru.fromchat.api.Message +import ru.fromchat.api.generateClientMessageId +import ru.fromchat.api.nowMessageTimestampIso +import ru.fromchat.api.sortMessagesForChatDisplay import ru.fromchat.api.WebSocketMessage import ru.fromchat.core.Logger +import ru.fromchat.ui.chat.dedupeMessagesByClientId +import ru.fromchat.ui.chat.dropSupersededOptimisticMessages import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -70,6 +76,17 @@ abstract class ChatPanel( */ fun getState(): ChatPanelState = _state.copy() + /** Merge SQLDelight rows with in-memory optimistic attachment UI (pending preview, thumbnails). */ + suspend fun syncMessagesFromDatabase(messages: List) { + batchStateUpdates { + updateState { current -> + val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages) + if (current.messages == merged) current + else current.copy(messages = merged) + } + } + } + /** * Update state */ @@ -103,9 +120,9 @@ abstract class ChatPanel( batchDepth-- if (batchDepth == 0) { val callback = onStateChange - val stateToSend = pendingBatchedState pendingBatchedState = null - if (callback != null && stateToSend != null) { + val stateToSend = _state.copy() + if (callback != null) { scope.launch(Dispatchers.Main) { Logger.d( "ChatPanel", @@ -142,7 +159,7 @@ abstract class ChatPanel( if (!messageExists) { Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}") updateState { currentState -> - val newMessages = currentState.messages + message + val newMessages = sortMessagesForChatDisplay(currentState.messages + message) Logger.d("ChatPanel", "Messages count after add: ${newMessages.size}") currentState.copy(messages = newMessages) } @@ -170,7 +187,11 @@ abstract class ChatPanel( } if (newOnes.isNotEmpty()) { updateState { currentState -> - val newMessages = (currentState.messages + newOnes).sortedBy { it.timestamp } + val merged = dropSupersededOptimisticMessages( + currentState.messages + newOnes, + ApiClient.user?.id, + ) + val newMessages = sortMessagesForChatDisplay(dedupeMessagesByClientId(merged)) currentState.copy(messages = newMessages) } } @@ -194,6 +215,40 @@ abstract class ChatPanel( } } + fun updateMessageByClientMessageId(clientMessageId: String, updates: (Message) -> Message) { + val cid = clientMessageId.trim() + if (cid.isEmpty()) return + updateState { currentState -> + currentState.copy( + messages = currentState.messages.map { msg -> + if (msg.client_message_id == cid || msg.uploadJobId == cid) updates(msg) else msg + }, + ) + } + } + + /** Conversation id used for outbox / local DB (DM peer or public group). */ + abstract fun outboxConversationId(): String + + open suspend fun cancelQueuedMessage(message: Message) { + val cid = message.client_message_id?.trim().orEmpty() + if (cid.isEmpty()) return + if (message.pendingFileUri != null) { + clearOutboundImageCaches(cid, message.id) + } + removeMessage(message.id) + ru.fromchat.api.outbox.OutgoingMessageCoordinator.cancelOutboundMessage(cid, outboxConversationId()) + } + + suspend fun cancelQueuedMessageByClientId(clientMessageId: String) { + val cid = clientMessageId.trim() + if (cid.isEmpty()) return + val message = _state.messages.find { msg -> + msg.client_message_id == cid || msg.uploadJobId == cid + } ?: return + cancelQueuedMessage(message) + } + /** * Remove message from list */ @@ -214,6 +269,23 @@ abstract class ChatPanel( updateState { it.copy(messages = emptyList()) } } + /** In-flight sends only (active [pendingMessages]), not stale cache optimistics. */ + protected fun snapshotPendingOptimisticMessages(): List { + if (pendingMessages.isEmpty()) return emptyList() + val pendingClientIds = pendingMessages.keys + return _state.messages.filter { msg -> + val cid = msg.client_message_id?.trim().orEmpty() + cid.isNotEmpty() && cid in pendingClientIds + } + } + + protected suspend fun restorePendingOptimisticMessages(messages: List) { + if (messages.isEmpty()) return + val filtered = dropSupersededOptimisticMessages(messages, ApiClient.user?.id) + if (filtered.isEmpty()) return + addMessages(filtered) + } + /** * Set loading state */ @@ -258,7 +330,7 @@ abstract class ChatPanel( mapped + confirmedMessage else -> mapped } - currentState.copy(messages = messages) + currentState.copy(messages = sortMessagesForChatDisplay(messages)) } scope.launch(Dispatchers.Default) { runCatching { onOptimisticMessageConfirmed(tempId, confirmedMessage) } @@ -274,7 +346,7 @@ abstract class ChatPanel( suspend fun retryMessage(messageId: Int) { val message = _state.messages.find { it.id == messageId } ?: return - val tempId = "temp_${Clock.System.now().toEpochMilliseconds()}_${(0..999999).random()}" + val tempId = generateClientMessageId() val newOptimistic = message.copy( id = uniqueOptimisticMessageId(), client_message_id = tempId @@ -334,12 +406,12 @@ abstract class ChatPanel( if (content.isBlank()) return // Create temporary message for immediate display - val tempId = "temp_${Clock.System.now().toEpochMilliseconds()}_${(0..999999).random()}" + val tempId = generateClientMessageId() val tempMessage = Message( id = -1, // Temporary negative ID user_id = currentUserId ?: -1, content = content.trim(), - timestamp = Clock.System.now().toString(), + timestamp = nowMessageTimestampIso(), is_read = false, is_edited = false, username = "You", @@ -391,6 +463,9 @@ abstract class ChatPanel( /** Persist optimistic row for offline / process death; no-op by default. */ protected open suspend fun persistOptimisticMessage(message: Message) {} + /** Persists an outbound row to the local DB (DM/public overrides). */ + suspend fun persistOutboundMessage(message: Message) = persistOptimisticMessage(message) + protected open suspend fun removeOptimisticFromCache(message: Message) {} /** 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 cd2b114..d41e653 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 @@ -34,6 +34,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -48,16 +49,24 @@ import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient -import ru.fromchat.api.AttachmentUploadJob -import ru.fromchat.api.AttachmentUploadQueue +import ru.fromchat.api.AttachmentUploadNotifier +import ru.fromchat.api.AttachmentUploadProgress +import ru.fromchat.api.generateClientMessageId +import ru.fromchat.api.nowMessageTimestampIso +import ru.fromchat.api.optimisticMessageIdForClientMessageId +import ru.fromchat.api.outbox.OutgoingMessageCoordinator +import com.pr0gramm3r101.utils.supportClipboardManagerImpl import ru.fromchat.api.ConnectionStateStore import ru.fromchat.api.ConnectionStatus import ru.fromchat.api.Message @@ -220,7 +229,26 @@ fun ChatScreen( var expandedImage by remember { mutableStateOf?>(null) } var isImageClosing by remember { mutableStateOf(false) } val imageThumbBounds = remember { mutableStateMapOf() } - val expandedImageKey = expandedImage?.let { (msg, idx) -> "img_${msg.id}_$idx" } + val expandedImageKey = expandedImage?.let { (msg, idx) -> + val cid = msg.client_message_id?.trim().orEmpty() + if (cid.isNotEmpty()) "img_${cid}_$idx" else "img_${msg.id}_$idx" + } + + LaunchedEffect(listState, panelState.messages, expandedImage) { + try { + snapshotFlow { + visibleMessageIdsInChatList( + listState = listState, + messages = panelState.messages, + extraMessageId = expandedImage?.first?.id, + ) + } + .distinctUntilChanged() + .collect { ids -> AttachmentDownloadVisibility.setVisibleMessageIds(ids) } + } finally { + AttachmentDownloadVisibility.setVisibleMessageIds(emptySet()) + } + } // Collect WebSocket messages LaunchedEffect(Unit) { @@ -311,11 +339,27 @@ fun ChatScreen( var didInitialScroll by rememberSaveable(panelId) { mutableStateOf(false) } // LaunchedEffect restarts when returning from profile even if message keys are unchanged; skip auto-scroll unless the list actually changed. var previousMessageFingerprint by rememberSaveable(panelId) { mutableStateOf("") } - LaunchedEffect(panelState.messages.size, panelState.messages.lastOrNull()?.id, panelState.messages.lastOrNull()?.pendingFileAspectRatio) { + var previousMessageCount by rememberSaveable(panelId) { mutableStateOf(-1) } + LaunchedEffect( + panelState.messages.size, + panelState.messages.lastOrNull()?.client_message_id, + panelState.messages.lastOrNull()?.uploadProgress, + panelState.messages.lastOrNull()?.pendingFileAspectRatio, + ) { if (panelState.messages.isEmpty()) return@LaunchedEffect + val newCount = panelState.messages.size + if (previousMessageCount >= 0 && newCount < previousMessageCount) { + previousMessageCount = newCount + val lastMessage = panelState.messages.lastOrNull() + previousMessageFingerprint = + "${newCount}|${lastMessage?.client_message_id}|${lastMessage?.uploadProgress}|${lastMessage?.pendingFileAspectRatio}" + return@LaunchedEffect + } + previousMessageCount = newCount + val lastMessage = panelState.messages.lastOrNull() - val fingerprint = "${panelState.messages.size}|${lastMessage?.id}|${lastMessage?.pendingFileAspectRatio}" + val fingerprint = "${newCount}|${lastMessage?.client_message_id}|${lastMessage?.uploadProgress}|${lastMessage?.pendingFileAspectRatio}" if (!didInitialScroll) { didInitialScroll = true @@ -341,19 +385,50 @@ fun ChatScreen( } } + val clipboard = supportClipboardManagerImpl + + LaunchedEffect(contextMenuState.isOpen, contextMenuState.message, panelState.messages, currentUserId, isReadOnly) { + if (!contextMenuState.isOpen) return@LaunchedEffect + val menuMessage = contextMenuState.message ?: return@LaunchedEffect + val cid = menuMessage.client_message_id?.trim().orEmpty() + val liveMessage = panelState.messages.find { msg -> + if (cid.isNotEmpty()) { + msg.client_message_id?.trim() == cid + } else { + msg.id == menuMessage.id + } + } + if (liveMessage == null) { + contextMenuState = contextMenuState.copy(isOpen = false, message = null) + return@LaunchedEffect + } + val menuAuthor = menuMessage.user_id == currentUserId + val liveAuthor = liveMessage.user_id == currentUserId + val menuFp = messageContextMenuFingerprint(menuMessage, menuAuthor, isReadOnly) + val liveFp = messageContextMenuFingerprint(liveMessage, liveAuthor, isReadOnly) + if (menuFp != liveFp) { + contextMenuState = contextMenuState.copy(isOpen = false, message = null) + } + } + LaunchedEffect(panel) { if (panel.getRecipientId() != null) { - AttachmentUploadQueue.progressFlow.collect { progress -> + AttachmentUploadNotifier.progressFlow.collect { progress -> when (progress) { - is ru.fromchat.api.AttachmentUploadProgress.InProgress -> - panel.updateMessage(-progress.jobId.hashCode().let { if (it == 0) -1 else it }) { - if (it.uploadJobId == progress.jobId) it.copy(uploadProgress = progress.percent) else it + is AttachmentUploadProgress.InProgress -> + panel.updateMessageByClientMessageId(progress.jobId) { + it.copy(uploadProgress = progress.percent) } - is ru.fromchat.api.AttachmentUploadProgress.Success -> - panel.updateMessage(-progress.jobId.hashCode().let { if (it == 0) -1 else it }) { - if (it.uploadJobId == progress.jobId) it.copy(uploadProgress = null) else it + is AttachmentUploadProgress.Success -> + panel.updateMessageByClientMessageId(progress.jobId) { + it.copy(uploadProgress = null) } - else -> {} + is AttachmentUploadProgress.Failed -> { + if (progress.error != "Cancelled") { + scope.launch { panel.cancelQueuedMessageByClientId(progress.jobId) } + } + } + else -> Unit } } } @@ -401,51 +476,75 @@ fun ChatScreen( if (attachments.isNotEmpty() && recipientId != null) { val plaintext = text.ifBlank { "" } attachments.forEach { att -> - val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}" - val hc = jobId.hashCode() - val absHc = if (hc == Int.MIN_VALUE) Int.MAX_VALUE else kotlin.math.abs(hc) - val tempId = -(absHc.let { if (it == 0) 1 else it }) + val jobId = generateClientMessageId() + val tempId = optimisticMessageIdForClientMessageId(jobId) val isImage = att.isImage - val optimisticMessage = Message( - id = tempId, - user_id = currentUserId ?: -1, - content = plaintext.ifBlank { att.filename }, - timestamp = Clock.System.now().toString(), - is_read = false, - is_edited = false, - username = "You", - profile_picture = null, - verified = null, - reply_to = replyTo, - client_message_id = jobId, - reactions = null, - files = null, - pendingFileUri = att.uri, - pendingFilename = att.filename, - uploadJobId = jobId, - uploadProgress = 0 - ) - panel.addMessage(optimisticMessage) - if (isImage) { - scope.launch { - val aspectRatio = getImageAspectRatio(att.uri) - if (aspectRatio != null && aspectRatio > 0f) { - panel.updateMessage(tempId) { - if (it.uploadJobId == jobId) it.copy(pendingFileAspectRatio = aspectRatio) else it - } - } + scope.launch(Dispatchers.Default) { + val imageDimensions = if (isImage) { + getImageDimensions(att.uri) + } else { + null } - } - AttachmentUploadQueue.enqueue( - AttachmentUploadJob( - jobId = jobId, - fileUri = att.uri, - filename = att.filename, + val aspectRatio = imageDimensions?.let { (w, h) -> + if (h > 0) w.toFloat() / h.toFloat() else null + }?.takeIf { it > 0f } + ?: if (isImage) { + getImageAspectRatio(att.uri)?.takeIf { it > 0f } + } else { + null + } + val staged = if (isImage) { + prepareOutboundImageForSend( + clientMessageId = jobId, + sourceUri = att.uri, + optimisticMessageId = tempId, + aspectRatio = aspectRatio, + ) + } else { + null + } + val fileUri = staged?.stagedUri ?: att.uri + val optimisticMessage = Message( + id = tempId, + user_id = currentUserId ?: -1, + content = plaintext.ifBlank { att.filename }, + timestamp = nowMessageTimestampIso(), + is_read = false, + is_edited = false, + username = "You", + profile_picture = null, + verified = null, + reply_to = replyTo, + client_message_id = jobId, + reactions = null, + files = null, + pendingFileUri = fileUri, + pendingFilename = att.filename, + uploadJobId = jobId, + uploadProgress = if (isImage) 0 else null, + pendingFileAspectRatio = staged?.aspectRatio ?: aspectRatio, + fileDimensions = imageDimensions?.let { listOf(it) }, + ) + withContext(Dispatchers.Main) { + panel.addMessage(optimisticMessage) + } + if (isImage && staged == null) { + withContext(Dispatchers.Main) { + panel.cancelQueuedMessageByClientId(jobId) + } + return@launch + } + OutgoingMessageCoordinator.enqueueDmAttachment( recipientId = recipientId, plaintext = plaintext.ifBlank { att.filename }, - replyToId = replyToId + clientMessageId = jobId, + replyToId = replyToId, + fileUri = fileUri, + filename = att.filename, + optimisticMessage = optimisticMessage, + aspectRatio = staged?.aspectRatio ?: aspectRatio, ) - ) + } } } else if (text.isNotBlank()) { panel.sendMessageWithImmediateDisplay(text, replyToId) @@ -516,7 +615,8 @@ fun ChatScreen( items( items = panelState.messages.asReversed(), key = { msg -> - msg.client_message_id ?: "id_${msg.id}_${msg.timestamp}" + val cid = msg.client_message_id?.trim().orEmpty() + if (cid.isNotEmpty()) "c:$cid" else "i:${msg.id}:${msg.timestamp}" } ) { message -> var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) } @@ -650,13 +750,23 @@ fun ChatScreen( panel.handleDeleteMessage(message.id) } }, + onCopy = { message -> + if (message.isContentCorrupted) return@MessageContextMenu + val text = message.content.trim() + if (text.isNotEmpty()) { + scope.launch { clipboard.setText(text) } + } + }, + onCancelSend = { message -> + scope.launch { panel.cancelQueuedMessage(message) } + }, ) } } } expandedImage?.let { (msg, idx) -> - val key = "img_${msg.id}_$idx" + val key = imageAttachmentKey(msg, idx) ImageFullscreenPreview( message = msg, fileIndex = idx, 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 69e1cfe..f0b2a0a 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 @@ -2,85 +2,458 @@ package ru.fromchat.ui.chat import com.pr0gramm3r101.utils.files.PlatformFileSystem import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import ru.fromchat.api.ApiClient 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 /** - * Disk cache for decrypted image bytes. Key includes messageId, fileIndex, and file.path - * so that server updates (e.g. image replacement) produce cache misses via path change. + * Disk + in-memory cache for decrypted DM images. + * Key is stable per attachment slot ([client_message_id] + index, or [messageId] + index). */ object DecryptedImageCache { + private const val SUBDIR = "decrypted_images" + + /** True when [uri] points at a file under this cache (safe to persist for offline preview). */ + fun isDecryptedImageCacheUri(uri: String?): Boolean { + if (uri.isNullOrBlank()) return false + val path = uri.removePrefix("file://") + return path.contains("/$SUBDIR/") || path.endsWith("/$SUBDIR") + } + private var cacheDir: String? = null - private val mutex = Mutex() + private val cacheMutex = Mutex() + private val memoryCache = mutableMapOf() fun init(cacheDirPath: String) { cacheDir = cacheDirPath } - private fun ensureCacheDir(): String? { - if (cacheDir == null) { - val base = PlatformFileSystem.getAppCacheDirectory() - if (base.isEmpty()) return null - cacheDir = PlatformFileSystem.ensureDirectory("$base/decrypted_images") + /** + * Stable identity for one attachment slot. Does not include [filePath] so confirm/scroll + * cannot miss cache when the server path string varies; edits/deletes invalidate explicitly. + */ + fun storageKey( + messageId: Int, + fileIndex: Int, + clientMessageId: String? = null, + ): String { + if (messageId > 0) { + return "img_${messageId}_$fileIndex" + } + val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } + return if (cid != null) { + "img_c_${sanitizeKeyPart(cid)}_$fileIndex" + } else { + "img_${messageId}_$fileIndex" } - return cacheDir } - private fun key(messageId: Int, fileIndex: Int, filePath: String): String { - val safePath = filePath.hashCode().toString(36).replace("-", "m") - return "img_${messageId}_${fileIndex}_$safePath" + /** Keys that may refer to the same slot (progress UI + cache aliases). */ + fun progressLookupKeys( + messageId: Int, + fileIndex: Int, + clientMessageId: String? = null, + ): List = buildList { + add(storageKey(messageId, fileIndex, clientMessageId)) + if (messageId > 0) add("img_${messageId}_$fileIndex") + val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } + if (cid != null) add("img_c_${sanitizeKeyPart(cid)}_$fileIndex") + }.distinct() + + fun resolveDownloadPercent( + messageId: Int, + fileIndex: Int, + clientMessageId: String? = null, + progressByKey: Map = emptyMap(), + ): Int? { + for (lookupKey in progressLookupKeys(messageId, fileIndex, clientMessageId)) { + progressByKey[lookupKey]?.let { return it } + } + return null } - fun getCached(messageId: Int, fileIndex: Int, filePath: String): String? { - val dir = ensureCacheDir() ?: return null - val path = "$dir/${key(messageId, fileIndex, filePath)}" - return if (PlatformFileSystem.exists(path)) "file://$path" else null + /** Synchronous disk lookup (safe to call from the main thread during composition). */ + fun getCached( + messageId: Int, + fileIndex: Int, + clientMessageId: String? = null, + ): String? { + val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } + if (cid != null) { + readDisk(storageKey(messageId, fileIndex, cid))?.let { return it } + } + return readDisk(storageKey(messageId, fileIndex, null)) } + fun getUriForStorageKey(storageKey: String): String? = readDisk(storageKey) + + fun messageIdFromStorageKey(storageKey: String): Int? { + if (!storageKey.startsWith("img_") || storageKey.startsWith("img_c_")) return null + return storageKey.removePrefix("img_").substringBefore('_').toIntOrNull() + } + + /** After confirm, cache file may only exist under client-id key; copy to message-id key for reopen. */ + suspend fun ensureDiskAliasForMessageId( + messageId: Int, + fileIndex: Int, + clientMessageId: String?, + ) { + if (messageId <= 0) return + val idKey = storageKey(messageId, fileIndex, null) + if (readDisk(idKey) != null) return + val cid = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } ?: return + val cidKey = storageKey(messageId, fileIndex, cid) + val sourceUri = readDisk(cidKey) ?: return + val bytes = runCatching { + ru.fromchat.core.cache.readOutboundFileBytes(sourceUri) + }.getOrNull() ?: return + if (bytes.isEmpty()) return + withContext(Dispatchers.Default + NonCancellable) { + cacheMutex.withLock { + if (readDisk(idKey) == null) { + writeCacheLocked(idKey, bytes) + AttachmentMediaLog.diskCache( + "alias_ok", + "from" to cidKey, + "to" to idKey, + "bytes" to bytes.size, + ) + } + } + } + } + + /** + * Returns a cached file URI, decrypting and persisting once per [storageKey]. + */ suspend fun getOrDecrypt( messageId: Int, fileIndex: Int, file: DmFile, envelope: DmEnvelope?, - currentUserId: Int? + currentUserId: Int?, + clientMessageId: String? = null, + messageLabel: String? = null, ): String? { if (envelope == null) return null - val dir = ensureCacheDir() ?: return null - val k = key(messageId, fileIndex, file.path) - val path = "$dir/$k" + val key = storageKey(messageId, fileIndex, clientMessageId) - mutex.withLock { - if (PlatformFileSystem.exists(path)) return "file://$path" + getCached(messageId, fileIndex, clientMessageId)?.let { uri -> + AttachmentMediaLog.diskCache( + "getOrDecrypt_hit", + "key" to key, + "msgId" to messageId, + "clientId" to clientMessageId, + "uri" to uri, + ) + return uri } - val bytes = runCatching { - withContext(Dispatchers.Default) { - decryptFile(file, envelope, currentUserId) - } - }.getOrNull() ?: return null + AttachmentMediaLog.diskCache( + "getOrDecrypt_miss", + "key" to key, + "msgId" to messageId, + "clientId" to clientMessageId, + "file" to file.path, + ) - mutex.withLock { - PlatformFileSystem.writeBytes(path, bytes) + cacheMutex.withLock { resolveUriLocked(key) }?.let { return it } + + val label = AttachmentMediaLog.messageLabel(messageLabel) + val uri = withContext(Dispatchers.Default + NonCancellable) { + runCatching { + AttachmentDownloadScheduler.run(storageKey = key, messageId = messageId) { + 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 -> + 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() } - return "file://$path" + if (uri != null && messageId > 0) { + ensureDiskAliasForMessageId(messageId, fileIndex, clientMessageId) + } + return uri } suspend fun invalidateForMessage(messageId: Int) { val dir = ensureCacheDir() ?: return withContext(Dispatchers.Default) { - PlatformFileSystem.deleteFilesWithPrefix(dir, "img_${messageId}_") + cacheMutex.withLock { + memoryCache.keys.removeAll { it.startsWith("img_${messageId}_") } + } + runCatching { + PlatformFileSystem.deleteFilesWithPrefix(dir, "img_${messageId}_") + } + LocalDecodedImageCache.evictPrefix("img_${messageId}_") } } - suspend fun invalidateForFile(messageId: Int, fileIndex: Int, filePath: String) { + suspend fun invalidateForClientMessage(clientMessageId: String) { + val cid = clientMessageId.trim() + if (cid.isEmpty()) return + val prefix = "img_c_${sanitizeKeyPart(cid)}_" val dir = ensureCacheDir() ?: return - val path = "$dir/${key(messageId, fileIndex, filePath)}" withContext(Dispatchers.Default) { - PlatformFileSystem.delete(path) + cacheMutex.withLock { + memoryCache.keys.removeAll { it.startsWith(prefix) } + } + runCatching { + PlatformFileSystem.deleteFilesWithPrefix(dir, prefix) + } + LocalDecodedImageCache.evictPrefix(prefix) } } + + suspend fun invalidateForFile( + messageId: Int, + fileIndex: Int, + clientMessageId: String? = null, + ) { + val key = storageKey(messageId, fileIndex, clientMessageId) + withContext(Dispatchers.Default) { + cacheMutex.withLock { memoryCache.remove(key) } + diskPath(key)?.let { invalidatePath(it) } + LocalDecodedImageCache.evict(key) + } + } + + suspend fun seedFromLocalFile( + messageId: Int, + fileIndex: Int, + localFileUri: String, + clientMessageId: String? = null, + ): String? = withContext(Dispatchers.Default + NonCancellable) { + val key = storageKey(messageId, fileIndex, clientMessageId) + cacheMutex.withLock { resolveUriLocked(key) }?.let { existing -> + AttachmentMediaLog.diskCache( + "seed_skip_exists", + "key" to key, + "uri" to existing, + ) + return@withContext existing + } + val t0 = AttachmentMediaLog.nowMs() + val bytes = runCatching { + ru.fromchat.core.cache.readOutboundFileBytes(localFileUri) + }.getOrNull() + if (bytes == null) { + AttachmentMediaLog.diskCache("seed_read_failed", "key" to key, "src" to localFileUri) + return@withContext null + } + val uri = cacheMutex.withLock { + resolveUriLocked(key) ?: writeCacheLocked(key, bytes) + } + AttachmentMediaLog.diskCache( + if (uri != null) "seed_ok" else "seed_write_failed", + "key" to key, + "bytes" to bytes.size, + "ms" to (AttachmentMediaLog.nowMs() - t0), + "uri" to uri, + ) + uri + } + + private suspend fun decryptAndPersist( + key: String, + messageId: Int, + fileIndex: Int, + clientMessageId: String?, + file: DmFile, + envelope: DmEnvelope, + currentUserId: Int?, + messageLabel: String? = null, + ): String? { + cacheMutex.withLock { resolveUriLocked(key) }?.let { return it } + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.InProgress(key, 1), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + val t0 = AttachmentMediaLog.nowMs() + val bytes = runCatching { + decryptFile( + file = file, + envelope = envelope, + currentUserId = currentUserId, + downloadResumeKey = key, + onDownloadProgress = { percent -> + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.InProgress(key, percent.coerceIn(0, 100)), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + }, + ) + }.onFailure { error -> + ApiClient.clearPartialEncryptedDownload(key) + AttachmentMediaLog.download( + "decrypt_failed", + "key" to key, + "msgId" to messageId, + "msg" to messageLabel, + "file" to file.path, + "err" to (error.message ?: error::class.simpleName), + ) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Failed(key, error.message ?: "download_failed"), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + }.getOrNull() + if (bytes == null) { + ApiClient.clearPartialEncryptedDownload(key) + return null + } + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.InProgress(key, 99), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + val uri = cacheMutex.withLock { + resolveUriLocked(key) ?: writeCacheLocked(key, bytes) + } + if (uri == null) { + ApiClient.clearPartialEncryptedDownload(key) + AttachmentMediaLog.download( + "decrypt_persist_failed", + "key" to key, + "msg" to messageLabel, + "bytes" to bytes.size, + ) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Failed(key, "cache_write_failed"), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + return null + } + AttachmentMediaLog.download( + "decrypt_persist_ok", + "key" to key, + "bytes" to bytes.size, + "ms" to (AttachmentMediaLog.nowMs() - t0), + "uri" to uri, + "msg" to messageLabel, + ) + AttachmentDownloadNotifier.emit( + AttachmentDownloadProgress.Success(storageKey = key, messageId = messageId), + messageLabel = messageLabel, + messageId = messageId, + fileIndex = fileIndex, + clientMessageId = clientMessageId, + ) + 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 + AttachmentMediaLog.diskCache("disk_hit", "key" to storageKey, "uri" to 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 sanitizeKeyPart(value: String): String = + value.replace(Regex("[^a-zA-Z0-9._-]"), "_") + + private fun diskPath(storageKey: String): String? { + val dir = ensureCacheDir() ?: return null + return "$dir/$storageKey" + } + + private fun readDisk(storageKey: String): String? { + val path = diskPath(storageKey) ?: return null + if (!PlatformFileSystem.exists(path)) return null + return "file://$path" + } + + private fun writeCacheLocked(storageKey: String, bytes: ByteArray): String? { + if (bytes.isEmpty()) return null + val path = diskPath(storageKey) ?: return null + return runCatching { + PlatformFileSystem.writeBytes(path, bytes) + if (!PlatformFileSystem.exists(path)) return null + val uri = "file://$path" + memoryCache[storageKey] = uri + uri + }.getOrElse { + invalidatePath(path) + null + } + } + + private fun invalidatePath(path: String) { + runCatching { PlatformFileSystem.delete(path) } + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt index 0370675..8665ab4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt @@ -61,16 +61,17 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.withContext import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch -import kotlinx.datetime.TimeZone import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import kotlinx.datetime.toLocalDateTime import ru.fromchat.api.Message +import ru.fromchat.api.formatMessageDateTimeLocal import ru.fromchat.* import ru.fromchat.ui.BackHandler import ru.fromchat.ui.LocalSystemBarsVisibility @@ -78,9 +79,6 @@ import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt -import kotlin.time.ExperimentalTime -import kotlin.time.Instant - private val MENU_BG_ALPHA = 0.5f private data class InitialTransform( @@ -91,22 +89,6 @@ private data class InitialTransform( val bgAlpha: Float ) -@OptIn(ExperimentalTime::class) -private fun formatDateTime(timestamp: String): String { - return try { - Instant.parse(timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).let { - val hour = it.hour.toString().padStart(2, '0') - val minute = it.minute.toString().padStart(2, '0') - val month = (it.month.ordinal + 1).toString().padStart(2, '0') - val day = it.day.toString().padStart(2, '0') - val year = it.year - "$month/$day/$year $hour:$minute" - } - } catch (_: Exception) { - timestamp - } -} - @Composable fun ImageFullscreenPreview( message: Message, @@ -133,18 +115,33 @@ fun ImageFullscreenPreview( val envelope = message.dmEnvelope val thumbnailBase64 = message.fileThumbnails?.getOrNull(fileIndex) - var cachedPath by remember(message.id, fileIndex, file.path) { - mutableStateOf(DecryptedImageCache.getCached(message.id, fileIndex, file.path)) + val cacheClientId = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() } + val decryptCacheKey = remember(message.id, fileIndex, cacheClientId) { + DecryptedImageCache.storageKey(message.id, fileIndex, cacheClientId) } - val thumbnailBytes = remember(thumbnailBase64) { - thumbnailBase64?.let { runCatching { com.pr0gramm3r101.utils.crypto.Base64.decode(it) }.getOrNull() } + val fsCacheKey = remember(decryptCacheKey) { + LocalDecodedImageCache.fullscreenCacheKey(decryptCacheKey) } - - LaunchedEffect(message.id, fileIndex, file.path, envelope) { - cachedPath = DecryptedImageCache.getOrDecrypt(message.id, fileIndex, file, envelope, currentUserId) + var cachedPath by remember(decryptCacheKey) { + mutableStateOf(DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId)) } - - val imageModel = cachedPath ?: thumbnailBytes + var previewBitmap by remember(decryptCacheKey) { + mutableStateOf(LocalDecodedImageCache.peekFull(decryptCacheKey)) + } + var fullscreenBitmap by remember(fsCacheKey) { + mutableStateOf(LocalDecodedImageCache.peekFullscreen(decryptCacheKey)) + } + val hasInstantBitmap = previewBitmap != null || fullscreenBitmap != null + val openAspectFromBitmap = previewBitmap?.let { it.width.toFloat() / it.height.toFloat() } + val layoutAspectHint = imageAspectRatioForMessage( + fileAspectRatios = message.fileAspectRatios, + fileDimensions = message.fileDimensions, + pendingFileAspectRatio = message.pendingFileAspectRatio, + fileIndex = fileIndex, + confirmed = message.id > 0, + ) + val thumbLayoutAspect = thumbnailBounds?.takeIf { it.width > 0f && it.height > 0f } + ?.let { bounds -> bounds.width / bounds.height } var menusVisible by remember { mutableStateOf(true) } var dismissRequested by remember { mutableStateOf(false) } @@ -186,11 +183,45 @@ fun ImageFullscreenPreview( ) { val containerWidth = constraints.maxWidth.toFloat() val containerHeight = constraints.maxHeight.toFloat() - val fileAspectRatio = message.fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f } - val contentHeightAtScale1 = if (fileAspectRatio != null) containerWidth / fileAspectRatio else containerHeight + val fullscreenDecodeSize = remember(constraints.maxWidth, constraints.maxHeight) { + ChatPreviewDecodeSize( + widthPx = constraints.maxWidth.coerceAtLeast(1), + heightPx = constraints.maxHeight.coerceAtLeast(1), + ) + } - when (val model = imageModel) { - null -> { + LaunchedEffect(decryptCacheKey, fsCacheKey, fullscreenDecodeSize) { + val uri = cachedPath ?: DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId) + ?: DecryptedImageCache.getOrDecrypt( + messageId = message.id, + fileIndex = fileIndex, + file = file, + envelope = envelope, + currentUserId = currentUserId, + clientMessageId = cacheClientId, + messageLabel = message.content, + ).also { cachedPath = it } + if (uri == null) return@LaunchedEffect + val hiRes = withContext(Dispatchers.Default) { + LocalDecodedImageCache.loadFullscreen(decryptCacheKey, uri, fullscreenDecodeSize) + } + if (hiRes != null) { + fullscreenBitmap = hiRes + } + } + + val displayBitmap = fullscreenBitmap ?: previewBitmap + val displayAspect = displayBitmap?.let { bmp -> + bmp.width.toFloat() / bmp.height.toFloat() + } ?: message.fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f } + val contentHeightAtScale1 = if (displayAspect != null) { + containerWidth / displayAspect + } else { + containerHeight + } + + when { + displayBitmap == null -> { Box( modifier = Modifier .fillMaxSize() @@ -205,14 +236,23 @@ fun ImageFullscreenPreview( ) } else -> { + val layoutAspect = openAspectFromBitmap + ?: thumbLayoutAspect + ?: layoutAspectHint + ?: displayAspect + val layoutContentHeight = if (layoutAspect != null) { + containerWidth / layoutAspect + } else { + contentHeightAtScale1 + } val initial = remember( - thumbnailBounds, containerWidth, containerHeight, contentHeightAtScale1 + thumbnailBounds, containerWidth, containerHeight, layoutContentHeight, ) { - if (thumbnailBounds != null && fileAspectRatio != null) { - val fullTop = (containerHeight - contentHeightAtScale1) / 2f + if (thumbnailBounds != null && layoutAspect != null) { + val fullTop = (containerHeight - layoutContentHeight) / 2f val fullCenter = Offset( x = containerWidth / 2f, - y = fullTop + contentHeightAtScale1 / 2f + y = fullTop + layoutContentHeight / 2f, ) val thumbCenter = thumbnailBounds.center val thumbWidth = thumbnailBounds.width @@ -244,10 +284,10 @@ fun ImageFullscreenPreview( hasPlayedOpenAnimation = true isOpenAnimationPlaying = true - val fullTop = (containerHeight - contentHeightAtScale1) / 2f + val fullTop = (containerHeight - layoutContentHeight) / 2f val fullCenter = Offset( x = containerWidth / 2f, - y = fullTop + contentHeightAtScale1 / 2f + y = fullTop + layoutContentHeight / 2f, ) val thumbCenter = thumbnailBounds.center val thumbWidth = thumbnailBounds.width @@ -261,17 +301,24 @@ fun ImageFullscreenPreview( offsetXAnim.snapTo(startOffset.x) offsetYAnim.snapTo(startOffset.y) cornerRadiusAnim.snapTo(12f) - backgroundAlpha.snapTo(0f) + backgroundAlpha.snapTo(if (hasInstantBitmap) 1f else 0f) menusVisible = false coroutineScope { - launch { scaleAnim.animateTo(1f, tween(250)) } - launch { offsetXAnim.animateTo(0f, tween(250)) } - launch { offsetYAnim.animateTo(0f, tween(250)) } - launch { cornerRadiusAnim.animateTo(0f, tween(250)) } - launch { backgroundAlpha.animateTo(1f, tween(250)) } + joinAll( + launch { scaleAnim.animateTo(1f, tween(280)) }, + launch { offsetXAnim.animateTo(0f, tween(280)) }, + launch { offsetYAnim.animateTo(0f, tween(280)) }, + launch { cornerRadiusAnim.animateTo(0f, tween(280)) }, + launch { + if (!hasInstantBitmap) { + backgroundAlpha.animateTo(1f, tween(280)) + } + }, + ) } - + scale = 1f + offset = Offset.Zero isOpenAnimationPlaying = false menusVisible = true } @@ -296,10 +343,10 @@ fun ImageFullscreenPreview( val wasOpenAnimating = isOpenAnimationPlaying isOpenAnimationPlaying = false - val fullTop = (containerHeight - contentHeightAtScale1) / 2f + val fullTop = (containerHeight - layoutContentHeight) / 2f val fullCenter = Offset( x = containerWidth / 2f, - y = fullTop + contentHeightAtScale1 / 2f + y = fullTop + layoutContentHeight / 2f, ) val thumbCenter = thumbnailBounds.center val thumbWidth = thumbnailBounds.width @@ -385,7 +432,7 @@ fun ImageFullscreenPreview( val clampedScale = scale.coerceIn(1f, 10f) val scaledW = containerWidth * clampedScale - val scaledH = contentHeightAtScale1 * clampedScale + val scaledH = layoutContentHeight * clampedScale val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f) val maxOffsetY = max(0f, (scaledH - containerHeight) / 2f) val clampedOffset = when { @@ -420,7 +467,10 @@ fun ImageFullscreenPreview( val raw = (abs(dragY) / (containerHeight * 0.75f)).coerceIn(0f, 1f) raw * raw } else 0f - val effectiveBackgroundAlpha = (if (isInitialOpenState) 0f else backgroundAlpha.value) * (1f - positionBasedProgress) + val effectiveBackgroundAlpha = ( + if (isInitialOpenState && !hasInstantBitmap) 0f + else backgroundAlpha.value + ) * (1f - positionBasedProgress) SideEffect { effectiveBgAlpha = effectiveBackgroundAlpha } val sharedElementModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) { @@ -431,15 +481,18 @@ fun ImageFullscreenPreview( ) } } else Modifier - val sizeModifier = if (fileAspectRatio != null) Modifier.fillMaxWidth().aspectRatio(fileAspectRatio) - else Modifier.fillMaxSize() + val sizeModifier = if (layoutAspect != null) { + Modifier.fillMaxWidth().aspectRatio(layoutAspect) + } else { + Modifier.fillMaxSize() + } Box( modifier = Modifier .fillMaxSize() .then( if (isTransitioning) Modifier else Modifier.pointerInput( - containerWidth, containerHeight, contentHeightAtScale1, + containerWidth, containerHeight, layoutContentHeight, bottomInsetPx, scope ) { detectTransformGestures( @@ -474,7 +527,7 @@ fun ImageFullscreenPreview( centroid.y - centerY - pivotOffsetY * zoomChange ) - val contentHeightAtNewScale = contentHeightAtScale1 * newScale + val contentHeightAtNewScale = layoutContentHeight * newScale val scaledW = containerWidth * newScale val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f) val maxOffsetYRaw = max(0f, (contentHeightAtNewScale - containerHeight) / 2f) @@ -496,7 +549,7 @@ fun ImageFullscreenPreview( } offset = Offset(newX, newY) } else { - val contentHeightAtCurrentScale = contentHeightAtScale1 * scale + val contentHeightAtCurrentScale = layoutContentHeight * scale val canScrollVertically = contentHeightAtCurrentScale > containerHeight val absDx = abs(panChange.x) val absDy = abs(panChange.y) @@ -564,8 +617,8 @@ fun ImageFullscreenPreview( .then(sharedElementModifier) .offset { IntOffset(offsetXAnim.value.roundToInt(), offsetYAnim.value.roundToInt()) } ) { - AsyncImage( - model = model, + FullscreenBitmapImage( + bitmap = displayBitmap, contentDescription = file.name, modifier = Modifier .fillMaxSize() @@ -578,7 +631,7 @@ fun ImageFullscreenPreview( shape = androidx.compose.foundation.shape.RoundedCornerShape((cornerRadiusAnim.value / scale).dp) clip = true }, - contentScale = ContentScale.FillWidth + contentScale = ContentScale.Fit ) } } @@ -587,18 +640,23 @@ fun ImageFullscreenPreview( } // Top bar: back, display name + date/time, 3-dot menu - AnimatedVisibility( - visible = effectiveMenusVisible, - enter = androidx.compose.animation.fadeIn(), - exit = androidx.compose.animation.fadeOut(), - modifier = Modifier.align(Alignment.TopStart).fillMaxWidth() + Box( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(Color.Black.copy(alpha = MENU_BG_ALPHA)) - .windowInsetsPadding(WindowInsets.systemBars) - .padding(horizontal = 8.dp, vertical = 12.dp), + AnimatedVisibility( + visible = effectiveMenusVisible, + enter = androidx.compose.animation.fadeIn(), + exit = androidx.compose.animation.fadeOut(), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = MENU_BG_ALPHA)) + .padding(horizontal = 8.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -619,7 +677,7 @@ fun ImageFullscreenPreview( color = Color.White ) Text( - text = formatDateTime(message.timestamp), + text = formatMessageDateTimeLocal(message.timestamp), style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.8f) ) @@ -682,29 +740,36 @@ fun ImageFullscreenPreview( } } } + } } // Bottom: message text - AnimatedVisibility( - visible = effectiveMenusVisible && message.content.isNotBlank(), - enter = androidx.compose.animation.fadeIn(), - exit = androidx.compose.animation.fadeOut(), - modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth() + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars), ) { - if (message.content.isNotBlank()) { - Box( - modifier = Modifier - .fillMaxWidth() - .background(Color.Black.copy(alpha = MENU_BG_ALPHA)) - .windowInsetsPadding(WindowInsets.systemBars) - .padding(16.dp) - ) { + AnimatedVisibility( + visible = effectiveMenusVisible && message.content.isNotBlank(), + enter = androidx.compose.animation.fadeIn(), + exit = androidx.compose.animation.fadeOut(), + modifier = Modifier.fillMaxWidth(), + ) { + if (message.content.isNotBlank()) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = MENU_BG_ALPHA)) + .padding(16.dp), + ) { Text( text = message.content, style = MaterialTheme.typography.bodyMedium, color = Color.White ) } + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.kt new file mode 100644 index 0000000..30cf2ab --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.kt @@ -0,0 +1,172 @@ +package ru.fromchat.ui.chat + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import kotlin.math.ceil + +/** Target decode dimensions in physical pixels for a chat attachment tile. */ +data class ChatPreviewDecodeSize(val widthPx: Int, val heightPx: Int) { + init { + require(widthPx > 0 && heightPx > 0) + } + + val longEdgePx: Int get() = maxOf(widthPx, heightPx) +} + +object LocalDecodedImageCache { + private const val THUMB_SUFFIX = "#thumb" + private const val FULLSCREEN_SUFFIX = "#fs" + + fun previewCacheKey(storageKey: String): String = storageKey + + fun fullscreenCacheKey(storageKey: String): String = storageKey + FULLSCREEN_SUFFIX + + fun peekFull(storageKey: String): ImageBitmap? = PlatformDecodedBitmapCache.get(storageKey) + + fun peekFullscreen(storageKey: String): ImageBitmap? = + PlatformDecodedBitmapCache.get(fullscreenCacheKey(storageKey)) + + fun peekThumb(storageKey: String): ImageBitmap? = + PlatformDecodedBitmapCache.get(storageKey + THUMB_SUFFIX) + + fun isBelowTarget(bitmap: ImageBitmap, target: ChatPreviewDecodeSize): Boolean = + bitmap.width < target.widthPx || bitmap.height < target.heightPx + + /** Avoid re-decoding on minor target growth after layout measure. */ + fun needsUpscale(bitmap: ImageBitmap, target: ChatPreviewDecodeSize): Boolean { + if (bitmap.width < target.widthPx * 0.88f) return true + if (bitmap.height < target.heightPx * 0.88f) return true + return false + } + + suspend fun loadFull( + storageKey: String, + fileUri: String, + target: ChatPreviewDecodeSize, + ): ImageBitmap? = loadIntoCache(previewCacheKey(storageKey), fileUri, target) + + suspend fun loadFullscreen( + storageKey: String, + fileUri: String, + target: ChatPreviewDecodeSize, + ): ImageBitmap? = loadIntoCache(fullscreenCacheKey(storageKey), fileUri, target) + + private suspend fun loadIntoCache( + cacheKey: String, + fileUri: String, + target: ChatPreviewDecodeSize, + ): ImageBitmap? { + PlatformDecodedBitmapCache.get(cacheKey)?.let { cached -> + if (!needsUpscale(cached, target)) { + AttachmentMediaLog.bitmapCache( + "memory_hit", + "cacheKey" to cacheKey, + "bmp" to "${cached.width}x${cached.height}", + "target" to "${target.widthPx}x${target.heightPx}", + ) + return cached + } + AttachmentMediaLog.bitmapCache( + "memory_evict_upscale", + "cacheKey" to cacheKey, + "bmp" to "${cached.width}x${cached.height}", + "target" to "${target.widthPx}x${target.heightPx}", + ) + PlatformDecodedBitmapCache.remove(cacheKey) + } + val path = fileUri.removePrefix("file://") + if (path.isEmpty()) return null + val t0 = AttachmentMediaLog.nowMs() + val bitmap = decodeLocalImageFile(path, target.widthPx, target.heightPx) + val elapsedMs = AttachmentMediaLog.nowMs() - t0 + if (bitmap == null) { + AttachmentMediaLog.bitmapCache( + "decode_failed", + "cacheKey" to cacheKey, + "path" to path, + "target" to "${target.widthPx}x${target.heightPx}", + "ms" to elapsedMs, + ) + return null + } + PlatformDecodedBitmapCache.put(cacheKey, bitmap) + AttachmentMediaLog.bitmapCache( + "decode_ok", + "cacheKey" to cacheKey, + "bmp" to "${bitmap.width}x${bitmap.height}", + "target" to "${target.widthPx}x${target.heightPx}", + "ms" to elapsedMs, + ) + return bitmap + } + + suspend fun loadThumb( + storageKey: String, + bytes: ByteArray, + displayTarget: ChatPreviewDecodeSize, + ): ImageBitmap? { + val key = storageKey + THUMB_SUFFIX + PlatformDecodedBitmapCache.get(key)?.let { return it } + val bitmap = decodeImageBytes( + bytes = bytes, + reqWidthPx = displayTarget.widthPx, + reqHeightPx = displayTarget.heightPx, + ) ?: return null + PlatformDecodedBitmapCache.put(key, bitmap) + return bitmap + } + + fun evict(storageKey: String) { + PlatformDecodedBitmapCache.remove(previewCacheKey(storageKey)) + PlatformDecodedBitmapCache.remove(fullscreenCacheKey(storageKey)) + PlatformDecodedBitmapCache.remove(storageKey + THUMB_SUFFIX) + } + + fun evictPrefix(prefix: String) = PlatformDecodedBitmapCache.evictPrefix(prefix) +} + +fun computeChatPreviewDecodeSize( + previewTileMax: Dp, + aspectRatio: Float?, + density: Density, +): ChatPreviewDecodeSize = with(density) { + val maxPx = ceil(previewTileMax.toPx()).toInt().coerceAtLeast(1) + val ratio = aspectRatio?.takeIf { it.isFinite() && it > 0f } ?: 1f + if (ratio >= 1f) { + ChatPreviewDecodeSize( + widthPx = maxPx, + heightPx = ceil(maxPx / ratio).toInt().coerceAtLeast(1), + ) + } else { + ChatPreviewDecodeSize( + widthPx = ceil(maxPx * ratio).toInt().coerceAtLeast(1), + heightPx = maxPx, + ) + } +} + +@Composable +internal fun rememberChatPreviewDecodeSize( + previewTileMax: Dp, + aspectRatio: Float?, +): ChatPreviewDecodeSize { + val density = LocalDensity.current + return remember(density, previewTileMax, aspectRatio) { + computeChatPreviewDecodeSize(previewTileMax, aspectRatio, density) + } +} + +expect object PlatformDecodedBitmapCache { + fun get(key: String): ImageBitmap? + fun put(key: String, bitmap: ImageBitmap) + fun remove(key: String) + fun evictPrefix(prefix: String) +} + +expect fun decodeLocalImageFile(absolutePath: String, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? + +expect fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? 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 3bd0002..7e983a4 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 @@ -19,6 +19,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Reply +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.Icon @@ -49,6 +51,7 @@ import androidx.compose.ui.window.PopupProperties import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.Message +import ru.fromchat.api.isQueuedOutbound import ru.fromchat.* import ru.fromchat.ui.scaleOnPress @@ -58,6 +61,25 @@ data class ContextMenuState( val position: IntOffset = IntOffset(0, 0) ) +/** Which context-menu rows would be shown; used to auto-dismiss when actions change. */ +internal fun messageContextMenuFingerprint( + message: Message, + isAuthor: Boolean, + isReadOnly: Boolean, +): String { + val isQueued = message.isQueuedOutbound() && isAuthor + val corrupted = message.isContentCorrupted + return buildString { + append("q=").append(isQueued) + append("|copy=").append(!corrupted) + if (!isQueued && !isReadOnly) { + append("|reply=1") + append("|edit=").append(isAuthor && !corrupted) + append("|del=").append(isAuthor) + } + } +} + @Suppress("AssignedValueIsNeverRead") @Composable fun MessageContextMenu( @@ -67,6 +89,8 @@ fun MessageContextMenu( onReply: (Message) -> Unit, onEdit: (Message) -> Unit, onDelete: (Message) -> Unit, + onCopy: (Message) -> Unit, + onCancelSend: (Message) -> Unit, isReadOnly: Boolean = false, screenWidthPx: Int, screenHeightPx: Int, @@ -126,6 +150,8 @@ fun MessageContextMenu( onReply = {}, onEdit = {}, onDelete = {}, + onCopy = {}, + onCancelSend = {}, modifier = modifier.graphicsLayer(alpha = 0f), animated = false, withShadow = false, @@ -212,6 +238,14 @@ fun MessageContextMenu( onDelete(it) onDismiss() }, + onCopy = { + onCopy(it) + onDismiss() + }, + onCancelSend = { + onCancelSend(it) + onDismiss() + }, modifier = modifier, animated = true, scale = scale, @@ -232,6 +266,8 @@ private fun ContextMenuContent( onReply: (Message) -> Unit, onEdit: (Message) -> Unit, onDelete: (Message) -> Unit, + onCopy: (Message) -> Unit, + onCancelSend: (Message) -> Unit, isReadOnly: Boolean = false, modifier: Modifier, animated: Boolean, @@ -276,6 +312,9 @@ private fun ContextMenuContent( val labelReply = stringResource(Res.string.action_reply) val labelEdit = stringResource(Res.string.action_edit) val labelDelete = stringResource(Res.string.action_delete) + val labelCopy = stringResource(Res.string.action_copy) + val labelCancelSend = stringResource(Res.string.action_cancel_send) + val isQueued = message.isQueuedOutbound() && isAuthor Box(modifier = containerModifier) { Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape)) @@ -285,27 +324,41 @@ private fun ContextMenuContent( .verticalScroll(menuScrollState), verticalArrangement = Arrangement.spacedBy(itemSpacing) ) { - if (!isReadOnly) { + if (!message.isContentCorrupted) { + ContextMenuItem( + icon = Icons.Rounded.ContentCopy, + text = labelCopy, + onClick = { onCopy(message) } + ) + } + if (isQueued) { + ContextMenuItem( + icon = Icons.Rounded.Close, + text = labelCancelSend, + onClick = { onCancelSend(message) }, + isError = true + ) + } else if (!isReadOnly) { ContextMenuItem( icon = Icons.AutoMirrored.Rounded.Reply, text = labelReply, onClick = { onReply(message) } ) - } - if (isAuthor && !isReadOnly) { - ContextMenuItem( - icon = Icons.Rounded.Edit, - text = labelEdit, - onClick = { onEdit(message) } - ) - } - if (isAuthor && !isReadOnly) { - ContextMenuItem( - icon = Icons.Rounded.Delete, - text = labelDelete, - onClick = { onDelete(message) }, - isError = true - ) + if (isAuthor && !message.isContentCorrupted) { + ContextMenuItem( + icon = Icons.Rounded.Edit, + text = labelEdit, + onClick = { onEdit(message) } + ) + } + if (isAuthor) { + ContextMenuItem( + icon = Icons.Rounded.Delete, + text = labelDelete, + onClick = { onDelete(message) }, + isError = true + ) + } } } } @@ -339,7 +392,11 @@ private fun ContextMenuItem( .scaleOnPress( scale = 0.96f, onClick = onClick, - indication = LocalIndication.current + indication = LocalIndication.current, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), ) .padding(horizontal = 12.dp, vertical = 8.dp), contentAlignment = Alignment.CenterStart 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 7f516d3..67eb387 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 @@ -51,14 +51,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.pr0gramm3r101.utils.conditional -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.Message +import ru.fromchat.api.formatMessageTimeLocal import ru.fromchat.* -import kotlin.time.ExperimentalTime -import kotlin.time.Instant private fun isMessageCorrupted(message: Message): Boolean { val files = message.files ?: return false @@ -70,7 +67,6 @@ private fun isMessageCorrupted(message: Message): Boolean { } } -@OptIn(ExperimentalTime::class) @Composable fun MessageItem( message: Message, @@ -96,7 +92,7 @@ fun MessageItem( isMessageCorrupted(message) } val formattedTime = remember(message.timestamp) { - formatTime(message.timestamp) + formatMessageTimeLocal(message.timestamp) } val corruptedBody = stringResource(Res.string.message_corrupted) val editedSuffix = stringResource(Res.string.message_edited_suffix) @@ -419,84 +415,86 @@ fun MessageItem( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) ) } else { - val firstFile = message.files?.firstOrNull() - val firstFileIsImage = firstFile?.let { isImageFilename(it.name) } ?: false - val hasPendingServerImage = message.pendingFileUri != null && - firstFileIsImage && - message.dmEnvelope != null - if (message.pendingFileUri != null) { - val isPendingImage = message.pendingFilename?.let { isImageFilename(it) } ?: false - val pendingImageFile = firstFile.takeIf { isPendingImage && hasPendingServerImage } - val imageKey = if (isPendingImage) "img_${message.id}_0" else null + val primaryFile = message.files?.firstOrNull() + val primaryIsImage = primaryFile != null && isImageFilename(primaryFile.name) + val showPrimaryImageSlot = pendingIsImage || primaryIsImage + if (showPrimaryImageSlot) { + val imageKey = imageAttachmentKey(message, 0) + val awaitingServer = message.id < 0 && message.files.isNullOrEmpty() + val isOutboundPendingImage = awaitingServer && pendingIsImage + val awaitingServerAck = isOutboundPendingImage && + message.uploadProgress == null AttachmentPreview( - file = pendingImageFile, - dmEnvelope = if (pendingImageFile != null) message.dmEnvelope else null, - currentUserId = if (pendingImageFile != null) currentUserId else null, + file = primaryFile, + dmEnvelope = message.dmEnvelope, + currentUserId = currentUserId, pendingFileUri = message.pendingFileUri, pendingFilename = message.pendingFilename, - isUploading = message.uploadProgress != null, + isUploading = isOutboundPendingImage, + awaitingServerAck = awaitingServerAck, uploadProgress = message.uploadProgress, - fileThumbnail = if (pendingImageFile != null) { - message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() } - } else { - null - }, - fileAspectRatio = if (pendingImageFile != null) { - message.fileAspectRatios?.firstOrNull()?.takeIf { it > 0f } - ?: message.pendingFileAspectRatio - } else { - message.pendingFileAspectRatio - }, - fileSizeBytes = when { - pendingImageFile != null -> message.fileSizes?.firstOrNull() - !isPendingImage -> message.fileSizes?.firstOrNull() - else -> null - }, - messageId = if (pendingImageFile != null && isPendingImage) message.id else null, - fileIndex = if (pendingImageFile != null && isPendingImage) 0 else null, + fileThumbnail = message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() }, + fileAspectRatio = imageAspectRatioForMessage( + fileAspectRatios = message.fileAspectRatios, + fileDimensions = message.fileDimensions, + pendingFileAspectRatio = message.pendingFileAspectRatio, + fileIndex = 0, + confirmed = message.id > 0, + hasLocalPreview = DecryptedImageCache.isDecryptedImageCacheUri( + message.pendingFileUri, + ), + ), + fileSizeBytes = message.fileSizes?.firstOrNull(), + messageId = message.id, + fileIndex = 0, + clientMessageId = message.client_message_id, onFileClick = null, - onImageClick = if (isPendingImage && imageKey != null) { - { onImageClick?.invoke(message, 0) } - } else { - null - }, - onImageBounds = if (isPendingImage && imageKey != null && onImageBounds != null) { + onImageClick = { onImageClick?.invoke(message, 0) }, + onImageBounds = if (onImageBounds != null) { { rect -> onImageBounds.invoke(imageKey, rect) } } else { null }, - isExpanded = isPendingImage && - imageKey != null && - expandedImageKey != null && + isExpanded = expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing, isAuthor = isAuthor, - modifier = if (isPendingImage && firstContentIsImage) { + messageLabel = message.content, + modifier = if (firstContentIsImage) { Modifier.padding(all = 2.dp) } else { - Modifier.padding( - horizontal = if (isPendingImage) 2.dp else 12.dp, - vertical = if (isPendingImage) 2.dp else 4.dp - ) + Modifier.padding(horizontal = 2.dp, vertical = 4.dp) } ) } message.files?.forEachIndexed { index, file -> - if (message.pendingFileUri != null && index == 0) return@forEachIndexed + if (index == 0 && showPrimaryImageSlot && isImageFilename(file.name)) { + return@forEachIndexed + } val isImage = isImageFilename(file.name) - val imageKey = if (isImage) "img_${message.id}_$index" else null + val imageKey = if (isImage) imageAttachmentKey(message, index) else null val isFirstImage = index == 0 && isImage AttachmentPreview( file = file, dmEnvelope = message.dmEnvelope, currentUserId = currentUserId, - pendingFileUri = null, + pendingFileUri = if (index == 0) message.pendingFileUri else null, + pendingFilename = if (index == 0) message.pendingFilename else null, isUploading = false, fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() }, - fileAspectRatio = message.fileAspectRatios?.getOrNull(index)?.takeIf { it > 0f }, + fileAspectRatio = imageAspectRatioForMessage( + fileAspectRatios = message.fileAspectRatios, + fileDimensions = message.fileDimensions, + pendingFileAspectRatio = message.pendingFileAspectRatio, + fileIndex = index, + confirmed = message.id > 0, + hasLocalPreview = index == 0 && + DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri), + ), fileSizeBytes = message.fileSizes?.getOrNull(index), messageId = if (isImage) message.id else null, fileIndex = if (isImage) index else null, + clientMessageId = message.client_message_id, onFileClick = null, onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null, onImageBounds = if (isImage && imageKey != null && onImageBounds != null) { @@ -504,6 +502,7 @@ fun MessageItem( } else null, isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing, isAuthor = isAuthor, + messageLabel = message.content, modifier = if (isFirstImage && firstContentIsImage && isImage) { Modifier.padding(all = 2.dp) } else { @@ -515,7 +514,12 @@ fun MessageItem( ) } } - if (message.content.isNotBlank() && !isCorrupted) { + val hideFilenamePlaceholderCaption = message.pendingFileUri != null && + pendingIsImage && + message.files.isNullOrEmpty() && + message.pendingFilename != null && + message.content == message.pendingFilename + if (message.content.isNotBlank() && !isCorrupted && !hideFilenamePlaceholderCaption) { Text( text = message.content, style = MaterialTheme.typography.bodyMedium, @@ -529,14 +533,14 @@ fun MessageItem( } // Timestamp, sending indicator, and edited indicator - val isSendingText = message.id < 0 && message.uploadJobId == null + val isPendingOutbound = message.id < 0 && message.files.isNullOrEmpty() Row( modifier = Modifier .padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { - if (isSendingText) { + if (isPendingOutbound) { CircularProgressIndicator( modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp, @@ -582,32 +586,3 @@ fun MessageItem( } } -@ExperimentalTime -private fun formatTime(timestamp: String): String { - return try { - Instant.parse(timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).let { - "${ - it.hour.toString().padStart(2, '0') - }:${ - it.minute.toString().padStart(2, '0') - }" - } - } catch (_: Exception) { - // Fallback: try parsing without timezone if it fails - try { - val parts = timestamp.split("T") - if (parts.size == 2) { - val timePart = parts[1].split(".")[0] - if (timePart.length >= 5) { - timePart.take(5) // Return HH:mm - } else { - "" - } - } else { - "" - } - } catch (_: Exception) { - "" - } - } -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageListDedup.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageListDedup.kt new file mode 100644 index 0000000..c10948d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageListDedup.kt @@ -0,0 +1,80 @@ +package ru.fromchat.ui.chat + +import ru.fromchat.api.Message +import ru.fromchat.api.parseMessageTimestampMillis + +/** One row per [Message.client_message_id]; prefers confirmed (id > 0) over optimistic. */ +internal fun dedupeMessagesByClientId(messages: List): List { + if (messages.size <= 1) return messages + val order = ArrayList(messages.size) + val byKey = LinkedHashMap(messages.size) + for (msg in messages) { + val key = messageDedupeKey(msg) + if (!byKey.containsKey(key)) { + order.add(key) + } + val existing = byKey[key] + byKey[key] = when { + existing == null -> msg + else -> preferMessageForDedupe(existing, msg) + } + } + return order.mapNotNull { byKey[it] } +} + +internal fun imageAttachmentKey(message: Message, fileIndex: Int): String { + val cid = message.client_message_id?.trim().orEmpty() + return if (cid.isNotEmpty()) "img_${cid}_$fileIndex" else "img_${message.id}_$fileIndex" +} + +internal fun messageDedupeKey(msg: Message): String { + val cid = msg.client_message_id?.trim().orEmpty() + return if (cid.isNotEmpty()) "c:$cid" else "i:${msg.id}" +} + +/** Drops optimistic rows already represented by a confirmed message (same client id or recent own attachment). */ +internal fun dropSupersededOptimisticMessages( + messages: List, + currentUserId: Int?, +): List { + if (messages.none { it.id < 0 }) return messages + val confirmed = messages.filter { it.id > 0 } + val confirmedClientIds = confirmed.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet() + val self = currentUserId + return messages.filter { msg -> + if (msg.id >= 0) return@filter true + val cid = msg.client_message_id?.trim().orEmpty() + if (cid.isNotEmpty() && cid in confirmedClientIds) return@filter false + // In-flight uploads (file or image): keep until a confirmed row shares the same client id. + if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true + if (self == null || msg.user_id != self) return@filter true + val msgTime = parseMessageTimestampMillis(msg.timestamp) + val nearOwnConfirmed = confirmed.filter { it.user_id == self }.any { confirmedMsg -> + val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp) + msgTime != null && confirmedTime != null && + kotlin.math.abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS + } + if (!nearOwnConfirmed) return@filter true + val pendingIsAttachment = !msg.files.isNullOrEmpty() + val confirmedHasAttachment = confirmed.any { it.user_id == self && !it.files.isNullOrEmpty() } + when { + pendingIsAttachment && confirmedHasAttachment -> false + !pendingIsAttachment && !confirmedHasAttachment -> false + !pendingIsAttachment && confirmedHasAttachment -> false + else -> true + } + } +} + +private const val NEAR_DUPLICATE_MS = 180_000L + +private fun preferMessageForDedupe(existing: Message, incoming: Message): Message { + val preferred = when { + incoming.id > 0 && existing.id < 0 -> incoming + existing.id > 0 && incoming.id < 0 -> existing + incoming.id >= existing.id -> incoming + else -> existing + } + val other = if (preferred === incoming) existing else incoming + return mergeMessageUiFields(preferred, other) +} 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 new file mode 100644 index 0000000..763a72b --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageUiMerge.kt @@ -0,0 +1,71 @@ +package ru.fromchat.ui.chat + +import ru.fromchat.api.Message +import ru.fromchat.api.db.aspectRatioFromDimensionPair +import ru.fromchat.api.sortMessagesForChatDisplay +import ru.fromchat.ui.chat.DecryptedImageCache + +/** + * SQLDelight rows omit optimistic attachment fields; merge DB snapshot with in-memory UI state. + */ +internal fun mergeDatabaseMessagesWithPanelState( + panelMessages: List, + dbMessages: List, +): List { + val panelByClientId = panelMessages.mapNotNull { msg -> + msg.client_message_id?.trim()?.takeIf { it.isNotEmpty() }?.let { it to msg } + }.toMap() + val panelById = panelMessages.associateBy { it.id } + + val mergedDb = dbMessages.map { db -> + val panel = db.client_message_id?.trim()?.takeIf { it.isNotEmpty() }?.let { panelByClientId[it] } + ?: panelById[db.id] + mergeMessageUiFields(db, panel) + } + + val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet() + val mergedIds = mergedDb.map { it.id }.toSet() + val extraPanel = panelMessages.filter { panel -> + val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() } + when { + panel.id < 0 && cid != null && cid !in mergedClientIds -> true + panel.id > 0 && panel.id !in mergedIds && (cid.isNullOrEmpty() || cid !in mergedClientIds) -> true + else -> false + } + } + + return dedupeMessagesByClientId( + dropSupersededOptimisticMessages(mergedDb + extraPanel, ru.fromchat.api.ApiClient.user?.id), + ).let { sortMessagesForChatDisplay(it) } +} + +internal fun mergeMessageUiFields(db: Message, panel: Message?): Message { + if (panel == null) return db + val confirmed = db.id > 0 + val localPreview = db.pendingFileUri?.takeIf { DecryptedImageCache.isDecryptedImageCacheUri(it) } + ?: panel.pendingFileUri?.takeIf { DecryptedImageCache.isDecryptedImageCacheUri(it) } + return db.copy( + pendingFileUri = when { + confirmed -> localPreview ?: db.pendingFileUri + else -> panel.pendingFileUri ?: db.pendingFileUri + }, + pendingFilename = if (confirmed) null else panel.pendingFilename ?: db.pendingFilename, + pendingFileAspectRatio = if (confirmed) { + db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) } + ?: db.fileAspectRatios?.firstOrNull() + ?: db.pendingFileAspectRatio + } else { + panel.pendingFileAspectRatio ?: db.pendingFileAspectRatio + }, + uploadJobId = if (confirmed) null else panel.uploadJobId ?: db.uploadJobId, + uploadProgress = if (confirmed) null else panel.uploadProgress ?: db.uploadProgress, + files = db.files ?: panel.files, + dmEnvelope = db.dmEnvelope ?: panel.dmEnvelope, + fileThumbnails = db.fileThumbnails ?: panel.fileThumbnails, + fileAspectRatios = db.fileAspectRatios ?: panel.fileAspectRatios, + fileSizes = db.fileSizes ?: panel.fileSizes, + fileDimensions = db.fileDimensions ?: panel.fileDimensions, + content = db.content.ifBlank { panel.content }, + isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted, + ) +} 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 new file mode 100644 index 0000000..3ab8125 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/OutboundImageStaging.kt @@ -0,0 +1,60 @@ +package ru.fromchat.ui.chat + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import ru.fromchat.core.cache.CacheContext +import ru.fromchat.core.cache.stageOutboundFileForUpload + +data class StagedOutboundPreview( + val stagedUri: String, + val aspectRatio: Float?, +) + +/** + * Copy attachment into instance upload storage, seed disk + decoded bitmap caches. + * Upload worker reads the staged path; UI reads bitmap cache (no picker URI after revoke). + */ +suspend fun prepareOutboundImageForSend( + clientMessageId: String, + sourceUri: String, + optimisticMessageId: Int, + aspectRatio: Float?, +): 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 + + DecryptedImageCache.seedFromLocalFile( + messageId = optimisticMessageId, + fileIndex = 0, + localFileUri = staged.uri, + clientMessageId = clientMessageId, + ) + + val storageKey = DecryptedImageCache.storageKey(optimisticMessageId, 0, clientMessageId) + val decodeTarget = previewSeedDecodeSize(aspectRatio) + LocalDecodedImageCache.loadFull(storageKey, staged.uri, decodeTarget) + + StagedOutboundPreview(stagedUri = staged.uri, aspectRatio = aspectRatio) +} + +/** High-quality seed decode before the tile is measured (refined when laid out). */ +internal fun previewSeedDecodeSize(aspectRatio: Float?): ChatPreviewDecodeSize { + val longEdge = 1440 + val ratio = aspectRatio?.takeIf { it.isFinite() && it > 0f } ?: 1f + return if (ratio >= 1f) { + ChatPreviewDecodeSize(longEdge, (longEdge / ratio).toInt().coerceAtLeast(1)) + } else { + ChatPreviewDecodeSize((longEdge * ratio).toInt().coerceAtLeast(1), longEdge) + } +} + +suspend fun clearOutboundImageCaches(clientMessageId: String, optimisticMessageId: Int) { + withContext(Dispatchers.Default) { + DecryptedImageCache.invalidateForClientMessage(clientMessageId) + val storageKey = DecryptedImageCache.storageKey(optimisticMessageId, 0, clientMessageId) + LocalDecodedImageCache.evict(storageKey) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt index c4bfc69..f5db6ac 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt @@ -16,6 +16,10 @@ import ru.fromchat.api.TypingUpdateData import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.api.db.MessageCacheStore +import ru.fromchat.api.db.MessageRepository +import ru.fromchat.api.db.conversationIdForGroup +import ru.fromchat.api.db.GENERAL_PUBLIC_GROUP_ID +import ru.fromchat.api.outbox.OutgoingMessageCoordinator import ru.fromchat.core.Logger class PublicChatPanel( @@ -142,7 +146,15 @@ class PublicChatPanel( } override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { - ApiClient.sendMessage(content, replyToId, clientMessageId) + val cid = clientMessageId?.trim().orEmpty() + if (cid.isEmpty()) return + val optimistic = _state.messages.find { it.client_message_id == cid } ?: return + OutgoingMessageCoordinator.enqueuePublicMessage( + content = content, + replyToId = replyToId, + clientMessageId = cid, + optimisticMessage = optimistic, + ) } override suspend fun persistOptimisticMessage(message: Message) { @@ -307,7 +319,7 @@ class PublicChatPanel( DecryptedImageCache.invalidateForMessage(deletedData.message_id) removeMessage(deletedData.message_id) withContext(Dispatchers.Default) { - MessageCacheStore.markMessageDeleted("public", deletedData.message_id) + MessageRepository.markPublicMessageDeleted(deletedData.message_id) MessageCacheStore.replacePublicMessages(_state.messages) } } @@ -387,4 +399,6 @@ class PublicChatPanel( override fun showCallButton(): Boolean = false override fun getTypingHandler(): TypingHandler = typingHandler + + override fun outboxConversationId(): String = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID) } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt index d2bf869..d900041 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanelCache.kt @@ -3,6 +3,7 @@ package ru.fromchat.ui.chat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import ru.fromchat.core.cache.CacheContext /** * Single retained [PublicChatPanel] for the app session (same idea as [ru.fromchat.ui.dm.DmPanelCache]). @@ -23,6 +24,14 @@ object PublicChatPanelCache { private var cachedPanelKey: String? = null private var cachedDisplayTitle: String? = null private var cachedUserId: Int? = null + private var cachedInstanceId: String = "" + + fun onActiveInstanceChanged(instanceId: String) { + if (cachedInstanceId.isNotEmpty() && cachedInstanceId != instanceId) { + clear() + } + cachedInstanceId = instanceId + } private fun ensureScope() { if (!supervisorJob.isActive) { @@ -36,10 +45,16 @@ object PublicChatPanelCache { */ fun getOrCreateGeneralChat(displayTitle: String, currentUserId: Int?): PublicChatPanel { ensureScope() + val instanceId = CacheContext.activeInstanceId.value.trim() + if (instanceId.isNotEmpty() && cachedInstanceId.isNotEmpty() && cachedInstanceId != instanceId) { + clear() + } + if (instanceId.isNotEmpty()) cachedInstanceId = instanceId if ( panel != null && cachedPanelKey == GeneralPublicPanelKey && - cachedUserId == currentUserId + cachedUserId == currentUserId && + (instanceId.isEmpty() || cachedInstanceId == instanceId) ) { if (cachedDisplayTitle != displayTitle) { cachedDisplayTitle = displayTitle 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 c8a84e3..82b80d9 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 @@ -5,10 +5,14 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient +import ru.fromchat.api.db.MessageRepository +import ru.fromchat.core.cache.CacheContext import ru.fromchat.ui.isPublicChatVisible import ru.fromchat.* @@ -26,12 +30,21 @@ fun PublicChatScreen( PublicChatPanelCache.getOrCreateGeneralChat(publicChatTitle, currentUserId) } + val activeInstanceId by CacheContext.activeInstanceId.collectAsState() + LaunchedEffect(panel) { if (panel.getState().messages.isEmpty()) { panel.loadMessages() } } + LaunchedEffect(panel, activeInstanceId) { + if (activeInstanceId.isBlank()) return@LaunchedEffect + MessageRepository.observePublicMessages().collect { rows -> + panel.syncMessagesFromDatabase(rows) + } + } + // Track visibility for notifications DisposableEffect(Unit) { isPublicChatVisible = true diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt index 735c8cc..38453c7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt @@ -4,8 +4,10 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import ru.fromchat.core.cache.CacheContext import androidx.compose.ui.Modifier import androidx.navigation.NavController import ru.fromchat.ui.HapticFeedbackEvent @@ -39,8 +41,8 @@ fun DmChatRoute( animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier, ) { - val scope = rememberCoroutineScope() - val panel = remember(otherUserId, scope) { DmPanelCache.getOrCreate(otherUserId, scope) } + val activeInstanceId by CacheContext.activeInstanceId.collectAsState() + val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) } val haptic = rememberHapticFeedback() val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" } @@ -66,8 +68,8 @@ fun DmProfileRoute( animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier, ) { - val scope = rememberCoroutineScope() - val panel = remember(otherUserId, scope) { DmPanelCache.getOrCreate(otherUserId, scope) } + val activeInstanceId by CacheContext.activeInstanceId.collectAsState() + val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) } val haptic = rememberHapticFeedback() val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" } val stateSnapshot = panel.getState() 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 899bf97..0451ecb 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 @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -15,10 +16,19 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.api.ApiClient import ru.fromchat.api.DmEnvelope +import ru.fromchat.api.DmDeletedData import ru.fromchat.api.Message +import ru.fromchat.api.sortMessagesForChatDisplay import ru.fromchat.api.ProfileCache import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.db.MessageCacheStore +import ru.fromchat.api.AttachmentDownloadNotifier +import ru.fromchat.api.AttachmentDownloadProgress +import ru.fromchat.api.db.parseDmMessageContent +import ru.fromchat.api.db.resolveLocalPreviewUri +import ru.fromchat.api.db.MessageRepository +import ru.fromchat.api.db.conversationIdForDm +import ru.fromchat.api.outbox.OutgoingMessageCoordinator import ru.fromchat.api.visibleDisplayName import ru.fromchat.core.Logger import ru.fromchat.core.config.Config @@ -30,6 +40,11 @@ import ru.fromchat.ui.chat.ChatPanel import ru.fromchat.ui.chat.DecryptedImageCache import ru.fromchat.ui.chat.DmTypingHandler import ru.fromchat.ui.chat.TypingHandler +import ru.fromchat.ui.chat.dedupeMessagesByClientId +import ru.fromchat.ui.chat.dropSupersededOptimisticMessages +import ru.fromchat.ui.chat.imageAspectRatioForMessage +import ru.fromchat.ui.chat.AttachmentMediaLog +import ru.fromchat.ui.chat.isImageFilename class DmPanel( private val otherUserId: Int, @@ -74,6 +89,22 @@ class DmPanel( updateState { it.copy(typingUsers = users) } } } + coroutineScope.launch(Dispatchers.Default) { + AttachmentDownloadNotifier.progressFlow.collect { event -> + if (event !is AttachmentDownloadProgress.Success || event.messageId <= 0) return@collect + val uri = DecryptedImageCache.getUriForStorageKey(event.storageKey) ?: return@collect + withContext(Dispatchers.Main) { + updateMessage(event.messageId) { msg -> + msg.copy(pendingFileUri = uri) + } + } + MessageCacheStore.patchDmMessageLocalPreview( + otherUserId = otherUserId, + messageId = event.messageId, + localPreviewUri = uri, + ) + } + } coroutineScope.launch(Dispatchers.Default) { runCatching { ApiClient.getProfileById(otherUserId) @@ -109,11 +140,15 @@ class DmPanel( } override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { - ApiClient.sendDm( + val cid = clientMessageId?.trim().orEmpty() + if (cid.isEmpty()) return + val optimistic = _state.messages.find { it.client_message_id == cid } ?: return + OutgoingMessageCoordinator.enqueueDmMessage( recipientId = otherUserId, plaintext = content, - clientMessageId = clientMessageId, - replyToId = replyToId + clientMessageId = cid, + replyToId = replyToId, + optimisticMessage = optimistic, ) } @@ -128,11 +163,15 @@ class DmPanel( override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) { MessageCacheStore.confirmDmMessage(otherUserId, clientMessageId, confirmed) + OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(clientMessageId) } override suspend fun loadMessages() { setLoading(true) try { + OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance( + ru.fromchat.core.cache.CacheContext.requireActiveInstanceId(), + ) val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList()) if (cached.isNotEmpty()) { clearMessages() @@ -142,6 +181,7 @@ class DmPanel( val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) } if (historyResult.isSuccess) { val response = historyResult.getOrNull() ?: return + val optimisticSnapshot = snapshotPendingOptimisticMessages() clearMessages() val decryptedForLog = mutableListOf>() val messages = response.messages.map { envelope -> @@ -160,10 +200,18 @@ class DmPanel( } else msg } addMessages(messagesWithReplies) + restorePendingOptimisticMessages(optimisticSnapshot) + updateState { state -> + val cleaned = dedupeMessagesByClientId( + dropSupersededOptimisticMessages(state.messages, currentUserId), + ) + if (cleaned == state.messages) state else state.copy(messages = cleaned) + } setHasMoreMessages(false) // Persist the most recent DM messages for offline use. - MessageCacheStore.replaceDmMessages(otherUserId, messagesWithReplies) + val mergedForCache = _state.messages + MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache) } else { val error = historyResult.exceptionOrNull() Logger.e("DmPanel", "Failed to load DM history: ${error?.message}", error) @@ -186,6 +234,7 @@ class DmPanel( when (message.type) { "dmNew" -> message.data?.let { processEnvelope(it) } "dmEdited" -> message.data?.let { processEditedEnvelope(it) } + "dmDeleted" -> message.data?.let { processDeletedEnvelope(it) } "dmTyping" -> message.data?.let { data -> val obj = data.jsonObject val userId = obj["userId"]?.jsonPrimitive?.content?.toIntOrNull() @@ -205,6 +254,7 @@ class DmPanel( when (type) { "dmNew" -> obj["data"]?.let { processEnvelope(it) } "dmEdited" -> obj["data"]?.let { processEditedEnvelope(it) } + "dmDeleted" -> obj["data"]?.let { processDeletedEnvelope(it) } "dmTyping" -> obj["data"]?.let { data -> val dataObj = data.jsonObject val userId = dataObj["userId"]?.jsonPrimitive?.content?.toIntOrNull() @@ -230,10 +280,26 @@ class DmPanel( if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return scope.launch(Dispatchers.Default) { dmEnvelopeMutex.withLock { - val alreadyExists = _state.messages.any { it.id == envelope.id } + val cid = envelope.clientMessageId?.trim().orEmpty() val outcome = decryptDmEnvelopeForUi(envelope) - if (alreadyExists) return@withLock + if (envelope.senderId == currentUserId && cid.isNotEmpty()) { + val hasOptimistic = _state.messages.any { message -> + message.user_id == currentUserId && + (message.client_message_id == cid || message.uploadJobId == cid) + } + if (hasOptimistic) { + mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) + if (envelope.replyToId != null) { + val replyTo = _state.messages.find { it.id == envelope.replyToId } + updateMessage(envelope.id) { it.copy(reply_to = replyTo) } + } + MessageCacheStore.replaceDmMessages(otherUserId, _state.messages) + return@withLock + } + } + + if (_state.messages.any { it.id == envelope.id }) return@withLock if (envelope.senderId == currentUserId) { mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) @@ -245,7 +311,6 @@ class DmPanel( updateMessage(envelope.id) { it.copy(reply_to = replyTo) } } - // Persist updated DM thread to cache MessageCacheStore.replaceDmMessages(otherUserId, _state.messages) } } @@ -254,65 +319,95 @@ class DmPanel( private fun mergeConfirmedOwnMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean) { val confirmed = createMessage(envelope, plaintext, isContentCorrupted) val hasAttachments = !envelope.files.isNullOrEmpty() + val cid = envelope.clientMessageId?.trim().orEmpty() + val stateSourceBeforeMerge = _state.messages.firstOrNull { message -> + message.user_id == currentUserId && + cid.isNotEmpty() && + (message.client_message_id == cid || message.uploadJobId == cid) + } + val localUri = stateSourceBeforeMerge?.pendingFileUri + val dmFile = envelope.files?.firstOrNull() + val isImageAttachment = dmFile?.let { isImageFilename(it.name) } == true + val aspect = imageAspectRatioForMessage( + fileAspectRatios = confirmed.fileAspectRatios, + fileDimensions = confirmed.fileDimensions ?: stateSourceBeforeMerge?.fileDimensions, + pendingFileAspectRatio = stateSourceBeforeMerge?.pendingFileAspectRatio, + fileIndex = 0, + confirmed = true, + ) - updateState { currentState -> - val existingRealIndex = currentState.messages.indexOfFirst { it.id == envelope.id } - val byClientIdIndex = currentState.messages.indexOfFirst { message -> - message.id < 0 && - message.user_id == currentUserId && - envelope.clientMessageId != null && - (message.client_message_id == envelope.clientMessageId || message.uploadJobId == envelope.clientMessageId) + scope.launch(Dispatchers.Default) { + if (isImageAttachment && localUri != null && dmFile != null) { + DecryptedImageCache.seedFromLocalFile( + messageId = envelope.id, + fileIndex = 0, + localFileUri = localUri, + clientMessageId = cid, + ) + DecryptedImageCache.ensureDiskAliasForMessageId( + messageId = envelope.id, + fileIndex = 0, + clientMessageId = cid, + ) } - val exactOptimisticIndex = currentState.messages.indexOfFirst { message -> - message.user_id == currentUserId && - message.pendingFileUri != null && - envelope.clientMessageId != null && - (message.client_message_id == envelope.clientMessageId || message.uploadJobId == envelope.clientMessageId) - } - val optimisticIndex = when { - byClientIdIndex >= 0 -> byClientIdIndex - exactOptimisticIndex >= 0 -> exactOptimisticIndex - else -> currentState.messages.indexOfFirst { message -> - message.id < 0 && - message.user_id == currentUserId && - (message.pendingFileUri != null) == hasAttachments - } - } - - val stateSource = when { - optimisticIndex >= 0 -> currentState.messages[optimisticIndex] - existingRealIndex >= 0 -> currentState.messages[existingRealIndex] - else -> null - } - - val merged = confirmed.copy( - uploadJobId = stateSource?.uploadJobId, - pendingFileUri = stateSource?.pendingFileUri, - pendingFilename = stateSource?.pendingFilename, - pendingFileAspectRatio = stateSource?.pendingFileAspectRatio, - uploadProgress = stateSource?.uploadProgress + val localPreviewUri = resolveLocalPreviewUri( + confirmed.copy( + client_message_id = cid.ifEmpty { confirmed.client_message_id }, + pendingFileUri = localUri ?: confirmed.pendingFileUri, + ), ) - val newMessages = when { - optimisticIndex >= 0 -> { - currentState.messages.mapIndexedNotNull { index, message -> - when { - index == optimisticIndex -> merged - message.id == envelope.id -> null - else -> message + val merged = confirmed.copy( + uploadJobId = null, + uploadProgress = null, + pendingFileUri = if (isImageAttachment) localPreviewUri ?: localUri else null, + pendingFilename = null, + pendingFileAspectRatio = aspect, + fileAspectRatios = confirmed.fileAspectRatios ?: aspect?.let { listOf(it) }, + fileDimensions = confirmed.fileDimensions ?: stateSourceBeforeMerge?.fileDimensions, + ) + val mergedForPersistence = merged.copy(pendingFilename = null) + AttachmentMediaLog.persist( + "merge_confirmed", + "msgId" to envelope.id, + "clientId" to cid, + "localPreview" to (merged.pendingFileUri?.take(64) ?: "null"), + "aspect" to aspect, + ) + + withContext(Dispatchers.Main) { + updateState { currentState -> + val optimisticIndex = currentState.messages.indexOfFirst { message -> + message.user_id == currentUserId && + cid.isNotEmpty() && + (message.client_message_id == cid || message.uploadJobId == cid) + } + val existingRealIndex = currentState.messages.indexOfFirst { it.id == envelope.id } + val newMessages = when { + optimisticIndex >= 0 -> { + currentState.messages.mapIndexedNotNull { index, message -> + when { + index == optimisticIndex -> merged + message.id == envelope.id -> null + else -> message + } + } } + existingRealIndex >= 0 -> { + currentState.messages.mapIndexed { index, message -> + if (index == existingRealIndex) merged else message + } + } + else -> currentState.messages + merged } + currentState.copy(messages = dedupeMessagesByClientId(newMessages)) } - - existingRealIndex >= 0 -> { - currentState.messages.mapIndexed { index, message -> - if (index == existingRealIndex) merged else message - } - } - - else -> currentState.messages + merged } - currentState.copy(messages = newMessages) + + if (cid.isNotEmpty()) { + MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence) + OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid) + } } } @@ -322,15 +417,22 @@ class DmPanel( }.getOrNull() ?: return if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return scope.launch(Dispatchers.Default) { - DecryptedImageCache.invalidateForMessage(envelope.id) + val previous = _state.messages.find { it.id == envelope.id } + val filesChanged = previous?.files != envelope.files + if (filesChanged) { + DecryptedImageCache.invalidateForMessage(envelope.id) + envelope.clientMessageId?.trim()?.takeIf { it.isNotEmpty() }?.let { + DecryptedImageCache.invalidateForClientMessage(it) + } + } val outcome = decryptDmEnvelopeForUi(envelope) - val dec = parseDecryptedContent(outcome.plaintext) + val dec = parseDmMessageContent(outcome.plaintext) updateMessage(envelope.id) { it.copy( content = dec.text, is_edited = true, - fileThumbnails = dec.thumbnails ?: it.fileThumbnails, - fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios, + fileThumbnails = dec.fileThumbnails ?: it.fileThumbnails, + fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios, fileSizes = dec.fileSizes ?: it.fileSizes, fileDimensions = dec.fileDimensions ?: it.fileDimensions, isContentCorrupted = outcome.isCorrupted @@ -342,43 +444,8 @@ class DmPanel( } } - private data class DecryptedContent( - val text: String, - val thumbnails: List?, - val aspectRatios: List?, - val fileSizes: List?, - val fileDimensions: List>? - ) - - private fun parseDecryptedContent(plaintext: String): DecryptedContent { - return runCatching { - val obj = json.parseToJsonElement(plaintext).jsonObject - val text = obj["text"]?.jsonPrimitive?.content ?: return@runCatching DecryptedContent(plaintext, null, null, null, null) - val thumbArr = obj["fileThumbnails"]?.jsonArray ?: return@runCatching DecryptedContent(text, null, null, null, null) - val thumbnails = thumbArr.map { it.jsonPrimitive.content } - val arArr = obj["fileAspectRatios"]?.jsonArray - val parsed = arArr?.mapNotNull { elem -> - val a = elem as? JsonArray ?: return@mapNotNull null - if (a.size == 2) { - val w = (a.getOrNull(0) as? JsonPrimitive)?.content?.toIntOrNull() - val h = (a.getOrNull(1) as? JsonPrimitive)?.content?.toIntOrNull() - if (w != null && h != null && h > 0) Triple(w, h, w.toFloat() / h) else null - } else null - }?.takeIf { it.size == thumbnails.size } - val aspectRatios = parsed?.map { it.third } - val fileDimensions = parsed?.map { it.first to it.second } - val sizesArr = obj["fileSizes"]?.jsonArray - val fileSizes = sizesArr?.mapNotNull { (it as? JsonPrimitive)?.content?.toLongOrNull() }?.takeIf { it.size == thumbnails.size } - Logger.d("DmPanel", "parseDecryptedContent: thumbnails=${thumbnails.size}, aspectRatios=${aspectRatios?.size}, fileSizes=${fileSizes?.size}") - DecryptedContent(text, thumbnails.ifEmpty { null }, aspectRatios, fileSizes, fileDimensions) - }.getOrElse { - Logger.d("DmPanel", "parseDecryptedContent: parse failed, using plaintext fallback") - DecryptedContent(plaintext, null, null, null, null) - } - } - private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message { - val dec = parseDecryptedContent(plaintext) + val dec = parseDmMessageContent(plaintext) val username = if (envelope.senderId == currentUserId) { "You" } else { @@ -399,8 +466,8 @@ class DmPanel( reactions = null, files = envelope.files, dmEnvelope = envelope, - fileThumbnails = dec.thumbnails, - fileAspectRatios = dec.aspectRatios, + fileThumbnails = dec.fileThumbnails, + fileAspectRatios = dec.fileAspectRatios, fileSizes = dec.fileSizes, fileDimensions = dec.fileDimensions, isContentCorrupted = isContentCorrupted @@ -417,7 +484,43 @@ class DmPanel( } } - override suspend fun handleDeleteMessage(messageId: Int) {} + override suspend fun handleDeleteMessage(messageId: Int) { + if (messageId < 0) { + val queued = _state.messages.find { it.id == messageId } ?: return + cancelQueuedMessage(queued) + return + } + val clientId = _state.messages.find { it.id == messageId }?.client_message_id + deleteMessageImmediately(messageId) + DecryptedImageCache.invalidateForMessage(messageId) + clientId?.trim()?.takeIf { it.isNotEmpty() }?.let { + DecryptedImageCache.invalidateForClientMessage(it) + } + runCatching { ApiClient.deleteDm(messageId, otherUserId) } + withContext(Dispatchers.Default) { + MessageRepository.deleteDmMessageById(otherUserId, messageId) + } + } + + private fun processDeletedEnvelope(element: JsonElement) { + val data = runCatching { + json.decodeFromJsonElement(DmDeletedData.serializer(), element) + }.getOrNull() ?: return + val involvesPeer = + data.senderId == otherUserId || + data.recipientId == otherUserId || + data.senderId == currentUserId + if (!involvesPeer) return + scope.launch(Dispatchers.Default) { + val clientId = _state.messages.find { it.id == data.id }?.client_message_id + DecryptedImageCache.invalidateForMessage(data.id) + clientId?.trim()?.takeIf { it.isNotEmpty() }?.let { + DecryptedImageCache.invalidateForClientMessage(it) + } + deleteMessageImmediately(data.id) + MessageRepository.deleteDmMessageById(otherUserId, data.id) + } + } override fun showCallButton(): Boolean = Config.callsEnabled @@ -427,4 +530,6 @@ class DmPanel( override val showUsernamesInMessages: Boolean get() = false + + override fun outboxConversationId(): String = conversationIdForDm(otherUserId) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt index 67a6dda..411a970 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt @@ -1,23 +1,50 @@ package ru.fromchat.ui.dm import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import ru.fromchat.api.ApiClient +import ru.fromchat.core.cache.CacheContext /** * Cache of DM panels by otherUserId so that when navigating back from profile * we reuse the same panel and don't reload messages. + * + * Uses a [SupervisorJob] scope instead of [androidx.compose.runtime.rememberCoroutineScope] + * so [ru.fromchat.ui.chat.ChatPanel] state callbacks keep working after the composable is left. */ object DmPanelCache { - private val panels = mutableMapOf() + private var supervisorJob = SupervisorJob() + private var panelScope: CoroutineScope = CoroutineScope(supervisorJob + Dispatchers.Main.immediate) - fun getOrCreate( - otherUserId: Int, - scope: CoroutineScope - ): DmPanel { + private val panels = mutableMapOf() + private var cachedInstanceId: String = "" + + fun onActiveInstanceChanged(instanceId: String) { + if (cachedInstanceId.isNotEmpty() && cachedInstanceId != instanceId) { + clearAll() + } + cachedInstanceId = instanceId + } + + private fun ensureScope() { + if (!supervisorJob.isActive) { + supervisorJob = SupervisorJob() + panelScope = CoroutineScope(supervisorJob + Dispatchers.Main.immediate) + } + } + + fun getOrCreate(otherUserId: Int): DmPanel { + ensureScope() + val instanceId = CacheContext.activeInstanceId.value.trim() + if (instanceId.isNotEmpty() && cachedInstanceId.isNotEmpty() && cachedInstanceId != instanceId) { + clearAll() + } + if (instanceId.isNotEmpty()) cachedInstanceId = instanceId return panels.getOrPut(otherUserId) { DmPanel( otherUserId = otherUserId, - coroutineScope = scope, + coroutineScope = panelScope, currentUserId = ApiClient.user?.id ) } @@ -30,5 +57,6 @@ object DmPanelCache { fun clearAll() { panels.values.forEach { it.destroy() } panels.clear() + supervisorJob.cancel() } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt index 1f5b9a7..a34fbfe 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt @@ -5,9 +5,17 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import ru.fromchat.api.ApiClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import ru.fromchat.api.db.MessageRepository +import ru.fromchat.api.outbox.OutgoingMessageCoordinator +import ru.fromchat.api.outbox.scheduleOutboxProcessing +import ru.fromchat.core.cache.CacheContext import ru.fromchat.ui.chat.ChatScreen @Composable @@ -24,13 +32,33 @@ fun DmScreen( sharedAvatarKey: Any? = null ) { val currentUserId = ApiClient.user?.id + val activeInstanceId by CacheContext.activeInstanceId.collectAsState() + val otherUserId = panel.getState().profileUserId - LaunchedEffect(panel) { - if (panel.getState().messages.isEmpty()) { + LaunchedEffect(panel, otherUserId) { + if (otherUserId != null && otherUserId > 0) { panel.loadMessages() } } + LaunchedEffect(activeInstanceId, otherUserId) { + val peerId = otherUserId ?: return@LaunchedEffect + val instanceId = activeInstanceId.trim() + if (instanceId.isBlank() || peerId <= 0) return@LaunchedEffect + scheduleOutboxProcessing(instanceId) + withContext(Dispatchers.Default) { + OutgoingMessageCoordinator.drainOutboxForInstance(instanceId) + } + } + + LaunchedEffect(panel, activeInstanceId, otherUserId) { + val peerId = otherUserId ?: return@LaunchedEffect + if (activeInstanceId.isBlank() || peerId <= 0) return@LaunchedEffect + MessageRepository.observeDmMessages(peerId).collect { rows -> + panel.syncMessagesFromDatabase(rows) + } + } + ChatScreen( panel = panel, currentUserId = currentUserId, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt index 277a7c3..d2d97e9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt @@ -64,6 +64,9 @@ import ru.fromchat.api.UserStatus import ru.fromchat.api.UserStatusStore import ru.fromchat.api.db.CachedConversation import ru.fromchat.api.db.MessageCacheStore +import ru.fromchat.api.db.MessageRepository +import ru.fromchat.core.cache.CacheContext +import ru.fromchat.core.config.Config import ru.fromchat.app_name import ru.fromchat.chat_last_mesaage import ru.fromchat.net.NetworkConnectivity @@ -172,25 +175,25 @@ fun ChatsTab( } } - LaunchedEffect(Unit) { - // Load cached DM conversations first for instant offline display. - runCatching { - dmConversations = MessageCacheStore.loadCachedDmConversations() - } + val serverConfig by Config.serverConfig.collectAsState() + val activeInstanceId by CacheContext.activeInstanceId.collectAsState() + LaunchedEffect(serverConfig, activeInstanceId) { + if (activeInstanceId.isBlank()) return@LaunchedEffect runCatching { - val last = MessageCacheStore.loadRecentPublicMessages(1).lastOrNull() + dmConversations = MessageRepository.loadCachedDmConversations() + } + runCatching { + val last = MessageRepository.loadRecentPublicMessages(1).lastOrNull() publicLastMessagePreview = last?.content?.trim()?.takeIf { it.isNotEmpty() } } - - // Then refresh from network and update cache + state. runCatching { ApiClient.getDmConversations() }.onSuccess { conversations -> runCatching { conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } - MessageCacheStore.replaceDmConversations(conversations) - dmConversations = MessageCacheStore.loadCachedDmConversations() + MessageRepository.replaceDmConversations(conversations) + dmConversations = MessageRepository.loadCachedDmConversations() } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt index a23bcd3..973d21c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt @@ -62,6 +62,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.TabletAndroid import androidx.compose.material.icons.filled.VerifiedUser @@ -126,7 +127,15 @@ import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.about import ru.fromchat.back +import ru.fromchat.action_wipe_local_cache_confirm_body +import ru.fromchat.action_wipe_local_cache_confirm_title +import ru.fromchat.action_wipe_local_cache_done +import ru.fromchat.action_wipe_local_cache_supporting +import ru.fromchat.action_wipe_local_cache_title import ru.fromchat.api.ApiClient +import ru.fromchat.api.db.MessageRepository +import ru.fromchat.api.db.wipeLocalCacheOnDisk +import ru.fromchat.core.cache.writeFromChatCacheGeneration import ru.fromchat.ui.imeScrollWithKeyboard import ru.fromchat.api.DeviceSessionInfo import ru.fromchat.ui.main.settings.SettingsSecurityPredictiveBackHandler @@ -473,8 +482,14 @@ fun SettingsAppearanceScreen(onBack: () -> Unit) { @Composable fun SettingsServerToolsScreen(onBack: () -> Unit, outerNav: NavController) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + var showWipeLocalCacheConfirm by remember { mutableStateOf(false) } + var wipingLocalCache by remember { mutableStateOf(false) } + val wipeDoneMessage = stringResource(Res.string.action_wipe_local_cache_done) Scaffold( + snackbarHost = { FromChatSnackbarHost(snackbarHostState) }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { MediumTopAppBar( @@ -511,11 +526,57 @@ fun SettingsServerToolsScreen(onBack: () -> Unit, outerNav: NavController) { headline = stringResource(Res.string.debug_tools), supportingText = stringResource(Res.string.debug_tools_d), onClick = { outerNav.navigate("debug") }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness, leadingContent = { SettingsListLeadingIcon(Icons.Filled.BugReport) } ) + ListItem( + headline = stringResource(Res.string.action_wipe_local_cache_title), + supportingText = stringResource(Res.string.action_wipe_local_cache_supporting), + onClick = { if (!wipingLocalCache) showWipeLocalCacheConfirm = true }, + enabled = !wipingLocalCache, + divider = false, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.DeleteSweep) } + ) } } } + + if (showWipeLocalCacheConfirm) { + AlertDialog( + onDismissRequest = { if (!wipingLocalCache) showWipeLocalCacheConfirm = false }, + title = { Text(stringResource(Res.string.action_wipe_local_cache_confirm_title)) }, + text = { Text(stringResource(Res.string.action_wipe_local_cache_confirm_body)) }, + confirmButton = { + TextButton( + enabled = !wipingLocalCache, + onClick = { + wipingLocalCache = true + scope.launch { + runCatching { + wipeLocalCacheOnDisk() + writeFromChatCacheGeneration() + } + wipingLocalCache = false + showWipeLocalCacheConfirm = false + snackbarHostState.showSnackbar(wipeDoneMessage) + } + }, + ) { + Text(stringResource(Res.string.confirm)) + } + }, + dismissButton = { + TextButton( + enabled = !wipingLocalCache, + onClick = { showWipeLocalCacheConfirm = false }, + ) { + Text(stringResource(Res.string.cancel)) + } + }, + ) + } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt index 2082e0d..be13434 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/setup/ServerConfigScreen.kt @@ -137,7 +137,14 @@ import ru.fromchat.server_config_snackbar_api_fail import ru.fromchat.server_config_snackbar_defaults import ru.fromchat.server_config_snackbar_ok_api_calls_bad import ru.fromchat.server_config_snackbar_ok_calls +import ru.fromchat.server_config_snackbar_ok_calls_skip +import ru.fromchat.server_config_snackbar_timeout +import ru.fromchat.server_config_unsupported_no_instance_id +import ru.fromchat.server_config_checking import ru.fromchat.server_config_subtitle +import ru.fromchat.core.instance.ServerProbeResult +import ru.fromchat.core.instance.applyServerAndNavigate +import ru.fromchat.core.instance.probeServer import ru.fromchat.server_config_title import ru.fromchat.server_ip_hint import ru.fromchat.server_ip_label @@ -435,6 +442,8 @@ fun ServerConfigScreen() { } var busy by remember { mutableStateOf(false) } + var lastProbe by remember { mutableStateOf(null) } + var lastProbedConfig by remember { mutableStateOf(null) } var showResetDialog by remember { mutableStateOf(false) } val actionHazeState = rememberHazeState() val serverConfigListState = rememberLazyListState() @@ -445,6 +454,9 @@ fun ServerConfigScreen() { val strPortError = stringResource(Res.string.server_config_port_error) val strSnackbarApiFail = stringResource(Res.string.server_config_snackbar_api_fail) val strSnackbarOkApiCallsBad = stringResource(Res.string.server_config_snackbar_ok_api_calls_bad) + val strSnackbarTimeout = stringResource(Res.string.server_config_snackbar_timeout) + val strUnsupportedInstance = stringResource(Res.string.server_config_unsupported_no_instance_id) + val strChecking = stringResource(Res.string.server_config_checking) val strResetConfirmTitle = stringResource(Res.string.server_config_action_reset_confirm_title) val strResetConfirmBody = stringResource(Res.string.server_config_action_reset_confirm_body) @@ -473,50 +485,48 @@ fun ServerConfigScreen() { } } + fun buildTentativeConfig(): ServerConfigData? { + val host = serverIp.trim() + if (host.isEmpty() || !isValidIpOrHostname(host)) return null + if (apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText)) return null + if (callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText)) return null + return ServerConfigData( + serverIp = host, + apiPort = resolvedApiPort(apiPortText), + callsPort = effectiveCallsPort(callsPortText), + httpsEnabled = httpsEnabled, + ) + } + val verifyServer: () -> Unit = { scope.launch { - // Do not block actual checks on snackbar dismissal. - launch { snackbarHostState.showSnackbar("Checking...") } - val host = serverIp.trim() - if (host.isEmpty() || !isValidIpOrHostname(host)) { - snackbarHostState.showSnackbar(strHostError) + launch { snackbarHostState.showSnackbar(strChecking) } + val tentative = buildTentativeConfig() + if (tentative == null) { + snackbarHostState.showSnackbar( + if (serverIp.isNotEmpty() && !isValidIpOrHostname(serverIp.trim())) { + strHostError + } else { + strPortError + }, + ) return@launch } - if (apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText)) { - snackbarHostState.showSnackbar(strPortError) - return@launch - } - if (callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText)) { - snackbarHostState.showSnackbar(strPortError) - return@launch - } - val apiPort = resolvedApiPort(apiPortText) - val calls = effectiveCallsPort(callsPortText) - val tentative = ServerConfigData( - serverIp = host, - apiPort = apiPort, - callsPort = calls, - httpsEnabled = httpsEnabled, - ) - val apiBase = apiBaseUrlFor(tentative) - - val msg = runCatching { - withTimeout(3000) { - val pingMark = TimeSource.Monotonic.markNow() - val id = ApiClient.fetchServerInstanceId(apiBase) - val pingMs = pingMark.elapsedNow().inWholeMilliseconds - .toInt() - .coerceAtLeast(0) - if (id.isEmpty()) return@withTimeout strSnackbarApiFail - - val urlScheme = if (httpsEnabled) "https" else "http" - val root = "$urlScheme://${hostForAuthority(host)}:${calls}/" - val callsOk = ApiClient.probeHttpGet(root) - if (callsOk) getString(Res.string.server_config_snackbar_ok_calls, pingMs) - else strSnackbarOkApiCallsBad + val probe = probeServer(tentative) + lastProbe = probe + lastProbedConfig = tentative + val msg = when (probe) { + is ServerProbeResult.Supported -> { + if (probe.callsOk) { + getString(Res.string.server_config_snackbar_ok_calls, probe.pingMs) + } else { + getString(Res.string.server_config_snackbar_ok_calls_skip, probe.pingMs) + } } - }.getOrElse { strSnackbarApiFail } - + ServerProbeResult.Unsupported -> strUnsupportedInstance + ServerProbeResult.Timeout -> strSnackbarTimeout + ServerProbeResult.Unreachable -> strSnackbarApiFail + } snackbarHostState.showSnackbar(msg) } } @@ -605,61 +615,54 @@ fun ServerConfigScreen() { scope.launch { busy = true try { - val tentative = ServerConfigData( - serverIp = serverIp.trim(), - apiPort = apiPortEffective, - callsPort = callsPortParsed, - httpsEnabled = httpsEnabled, - ) - val tentativeApi = apiBaseUrlFor(tentative) - val newId = runCatching { - ApiClient.fetchServerInstanceId(tentativeApi) - }.getOrNull()?.trim().orEmpty() - if (newId.isEmpty()) { - withContext(Dispatchers.Main) { - reloginClearingSession(navController) + val tentative = buildTentativeConfig() ?: return@launch + val probe = if ( + lastProbedConfig == tentative && lastProbe != null + ) { + lastProbe!! + } else { + probeServer(tentative).also { + lastProbe = it + lastProbedConfig = tentative } - return@launch } - - val bearer = ApiClient.token?.trim().orEmpty() - if (bearer.isEmpty()) { - val callsOk = probeCallsReachable(tentative) - Config.updateServerConfig(tentative.copy(callsEnabled = callsOk)) - Settings.lastKnownServerInstanceId = newId - WebSocketManager.disconnect() - withContext(Dispatchers.Main) { - navController.navigateAndWipeBackStack("login") + when (probe) { + ServerProbeResult.Unsupported -> { + snackbarHostState.showSnackbar(strUnsupportedInstance) } - return@launch - } - - val persisted = Settings.lastKnownServerInstanceId.trim() - if (persisted.isNotEmpty() && !newId.equals(persisted, ignoreCase = true)) { - withContext(Dispatchers.Main) { - reloginClearingSession(navController) + ServerProbeResult.Timeout -> { + snackbarHostState.showSnackbar(strSnackbarTimeout) } - return@launch - } - - val authOk = ApiClient.checkAuthAt(tentativeApi, bearer) - if (!authOk) { - withContext(Dispatchers.Main) { - reloginClearingSession(navController) + ServerProbeResult.Unreachable -> { + snackbarHostState.showSnackbar(strSnackbarApiFail) } - return@launch - } - - val callsOk = probeCallsReachable(tentative) - Config.updateServerConfig(tentative.copy(callsEnabled = callsOk)) - Settings.lastKnownServerInstanceId = newId - WebSocketManager.disconnect() - WebSocketManager.connect(forceRestart = true) - withContext(Dispatchers.Main) { - if (!navController.popBackStack()) { - navController.navigate("chat") { - popUpTo("login") { inclusive = true } - } + is ServerProbeResult.Supported -> { + Settings.lastKnownServerInstanceId = probe.instanceId + val bearer = ApiClient.token?.trim().orEmpty() + applyServerAndNavigate( + probe = probe, + config = tentative, + bearer = bearer, + onNavigateLogin = { + withContext(Dispatchers.Main) { + navController.navigateAndWipeBackStack("login") + } + }, + onNavigateChat = { + withContext(Dispatchers.Main) { + if (!navController.popBackStack()) { + navController.navigate("chat") { + popUpTo("login") { inclusive = true } + } + } + } + }, + onLogoutOldHost = { + withContext(Dispatchers.Main) { + reloginClearingSession(navController) + } + }, + ) } } } finally { 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 e3cc665..6e621f6 100644 --- a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq +++ b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq @@ -1,15 +1,30 @@ +CREATE TABLE server_binding ( + configKey TEXT NOT NULL PRIMARY KEY, + activeInstanceId TEXT NOT NULL, + updatedAt TEXT +); + +CREATE TABLE instance_registry ( + instanceId TEXT NOT NULL PRIMARY KEY, + firstSeenAt TEXT NOT NULL, + lastSeenAt TEXT NOT NULL +); + CREATE TABLE conversation ( - id TEXT NOT NULL PRIMARY KEY, - type TEXT NOT NULL, -- "public" or "dm" + instanceId TEXT NOT NULL, + id TEXT NOT NULL, + type TEXT NOT NULL, otherUserId INTEGER, displayName TEXT, lastMessageId INTEGER, lastMessagePreview TEXT, unreadCount INTEGER NOT NULL DEFAULT 0, - updatedAt TEXT + updatedAt TEXT, + PRIMARY KEY (instanceId, id) ); CREATE TABLE message ( + instanceId TEXT NOT NULL, id INTEGER NOT NULL, conversationId TEXT NOT NULL, userId INTEGER NOT NULL, @@ -20,55 +35,120 @@ CREATE TABLE message ( replyToId INTEGER, clientMessageId TEXT, deletedFlag INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (id, conversationId) + sendStatus TEXT, + PRIMARY KEY (instanceId, conversationId, id) ); -CREATE INDEX message_conversation_index ON message(conversationId, timestamp); +CREATE INDEX message_instance_conversation_index ON message(instanceId, conversationId, timestamp); CREATE TABLE attachment ( + instanceId TEXT NOT NULL, id INTEGER NOT NULL, messageId INTEGER NOT NULL, conversationId TEXT NOT NULL, remotePath TEXT, localPath TEXT, - status TEXT NOT NULL, -- "PENDING", "DOWNLOADING", "READY" + status TEXT NOT NULL, blurhash TEXT, aspectRatio REAL, size INTEGER, - PRIMARY KEY (id, messageId, conversationId) + bytesTransferred INTEGER NOT NULL DEFAULT 0, + clientMessageId TEXT, + PRIMARY KEY (instanceId, id, messageId, conversationId) ); +CREATE TABLE outbox ( + instanceId TEXT NOT NULL, + clientMessageId TEXT NOT NULL, + conversationId TEXT NOT NULL, + kind TEXT NOT NULL, + payloadJson TEXT NOT NULL, + retryCount INTEGER NOT NULL DEFAULT 0, + nextAttemptAt TEXT, + bytesUploaded INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (instanceId, clientMessageId) +); + +CREATE TABLE profile_cache ( + instanceId TEXT NOT NULL, + userId INTEGER NOT NULL, + json TEXT NOT NULL, + PRIMARY KEY (instanceId, userId) +); + +-- server_binding +selectActiveInstanceIdForConfig: +SELECT activeInstanceId FROM server_binding WHERE configKey = ?; + +upsertServerBinding: +INSERT OR REPLACE INTO server_binding(configKey, activeInstanceId, updatedAt) +VALUES (?, ?, ?); + +deleteServerBinding: +DELETE FROM server_binding WHERE configKey = ?; + +-- instance_registry +upsertInstanceRegistry: +INSERT OR REPLACE INTO instance_registry(instanceId, firstSeenAt, lastSeenAt) +VALUES (?, ?, ?); + +touchInstanceRegistry: +UPDATE instance_registry SET lastSeenAt = ? WHERE instanceId = ?; + +selectAllInstanceIds: +SELECT instanceId FROM instance_registry; + +-- messages selectMessagesByConversation: SELECT * FROM message -WHERE conversationId = ? AND deletedFlag = 0 +WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 ORDER BY timestamp ASC; --- Last N rows for a conversation (newest first in SQL; reverse in Kotlin for chronological UI). selectRecentMessagesByConversation: SELECT * FROM message -WHERE conversationId = ? AND deletedFlag = 0 +WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 ORDER BY timestamp DESC LIMIT :limit; --- Optimistic rows only (avoid scanning the full conversation on replace). selectPendingMessagesByConversation: SELECT * FROM message -WHERE conversationId = ? AND deletedFlag = 0 AND id < 0 +WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 AND id < 0 ORDER BY timestamp ASC; +selectSentMessageIdByClientMessageId: +SELECT id +FROM message +WHERE instanceId = ? AND conversationId = ? AND clientMessageId = ? AND id > 0 AND deletedFlag = 0 +LIMIT 1; + +selectMessageById: +SELECT * +FROM message +WHERE instanceId = ? AND conversationId = ? AND id = ? AND deletedFlag = 0 +LIMIT 1; + deleteMessagesForConversation: DELETE FROM message -WHERE conversationId = ?; +WHERE instanceId = ? AND conversationId = ?; deleteMessageByClientMessageId: DELETE FROM message -WHERE conversationId = ? AND clientMessageId = ?; +WHERE instanceId = ? AND conversationId = ? AND clientMessageId = ?; + +deletePendingMessageByClientMessageId: +DELETE FROM message +WHERE instanceId = ? AND conversationId = ? AND clientMessageId = ? AND id < 0; + +deleteMessageById: +DELETE FROM message +WHERE instanceId = ? AND conversationId = ? AND id = ?; upsertMessage: INSERT OR REPLACE INTO message( + instanceId, id, conversationId, userId, @@ -78,21 +158,49 @@ INSERT OR REPLACE INTO message( isEdited, replyToId, clientMessageId, - deletedFlag -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + deletedFlag, + sendStatus +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); markMessageDeleted: UPDATE message SET deletedFlag = 1 -WHERE id = ? AND conversationId = ?; +WHERE instanceId = ? AND id = ? AND conversationId = ?; -selectConversations: +deleteAllMessagesForInstance: +DELETE FROM message WHERE instanceId = ?; + +deleteAllConversationsForInstance: +DELETE FROM conversation WHERE instanceId = ?; + +deleteAllOutboxForInstance: +DELETE FROM outbox WHERE instanceId = ?; + +deleteAllAttachmentsForInstance: +DELETE FROM attachment WHERE instanceId = ?; + +deleteAllProfilesForInstance: +DELETE FROM profile_cache WHERE instanceId = ?; + +purgeAllCache: +DELETE FROM message; +DELETE FROM conversation; +DELETE FROM outbox; +DELETE FROM attachment; +DELETE FROM profile_cache; +DELETE FROM server_binding; +DELETE FROM instance_registry; + +-- conversations +selectConversationsForInstance: SELECT * FROM conversation +WHERE instanceId = ? ORDER BY updatedAt DESC; upsertConversation: INSERT OR REPLACE INTO conversation( + instanceId, id, type, otherUserId, @@ -101,5 +209,43 @@ INSERT OR REPLACE INTO conversation( lastMessagePreview, unreadCount, updatedAt +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- outbox +selectPendingOutboxForInstance: +SELECT * FROM outbox WHERE instanceId = ? ORDER BY clientMessageId ASC; + +upsertOutbox: +INSERT OR REPLACE INTO outbox( + instanceId, + clientMessageId, + conversationId, + kind, + payloadJson, + retryCount, + nextAttemptAt, + bytesUploaded ) VALUES (?, ?, ?, ?, ?, ?, ?, ?); +deleteOutboxItem: +DELETE FROM outbox WHERE instanceId = ? AND clientMessageId = ?; + +selectOutboxItem: +SELECT * FROM outbox WHERE instanceId = ? AND clientMessageId = ? LIMIT 1; + +-- profile +upsertProfileCache: +INSERT OR REPLACE INTO profile_cache(instanceId, userId, json) +VALUES (?, ?, ?); + +selectProfileCache: +SELECT json FROM profile_cache WHERE instanceId = ? AND userId = ?; + +selectAllProfilesForInstance: +SELECT userId, json FROM profile_cache WHERE instanceId = ?; + +deleteProfileCache: +DELETE FROM profile_cache WHERE instanceId = ? AND userId = ?; + +deleteAllProfileCache: +DELETE FROM profile_cache; diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt index 9a0e25e..0531a7d 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/ApiClient.ios.kt @@ -22,8 +22,9 @@ actual fun createPlatformHttpClient( } install(HttpTimeout) { - requestTimeoutMillis = 30000 - connectTimeoutMillis = 30000 + requestTimeoutMillis = 30_000 + connectTimeoutMillis = 5_000 + socketTimeoutMillis = 30_000 } block(this) diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt index e191729..a78a475 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt @@ -1,45 +1,17 @@ package ru.fromchat.api -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.launch - -private val iosUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) +import ru.fromchat.api.outbox.scheduleOutboxProcessing +import ru.fromchat.core.cache.CacheContext actual object AttachmentUploadQueue { - private val _progressFlow = MutableSharedFlow(extraBufferCapacity = 64) - actual val progressFlow: SharedFlow = _progressFlow + actual val progressFlow: SharedFlow = AttachmentUploadNotifier.progressFlow actual fun enqueue(job: AttachmentUploadJob) { - _progressFlow.tryEmit(AttachmentUploadProgress.Pending(job.jobId, job.filename)) - iosUploadScope.launch { - // Phase 1 placeholder on iOS: eager message-only send to keep API functional. - // Full background resumable URLSession integration is completed in Phase 4. - runCatching { - ApiClient.sendDm( - recipientId = job.recipientId, - plaintext = job.plaintext, - clientMessageId = job.jobId, - replyToId = job.replyToId - ) - }.onSuccess { - _progressFlow.tryEmit(AttachmentUploadProgress.Success(job.jobId)) - }.onFailure { error -> - _progressFlow.tryEmit( - AttachmentUploadProgress.Failed( - jobId = job.jobId, - error = error.message ?: "Upload failed" - ) - ) - } - } + scheduleOutboxProcessing(CacheContext.activeInstanceId.value.trim()) } actual fun cancel(jobId: String) { - // No-op in the Phase 1 iOS eager placeholder implementation. + // Outbox + artifact cleanup handled when user deletes pending message from UI. } } - diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.ios.kt index 9506d85..d5a24ea 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseDriver.ios.kt @@ -2,12 +2,26 @@ package ru.fromchat.api.db import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSUserDomainMask import ru.fromchat.db.MessageDatabase +@OptIn(ExperimentalForeignApi::class) actual fun provideMessageDatabaseDriver(): SqlDriver { + val base = NSFileManager.defaultManager.URLForDirectory( + directory = NSCachesDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = true, + error = null, + )?.path ?: "." + val dir = "$base/fromchat" + NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null) return NativeSqliteDriver( schema = MessageDatabase.Schema, - name = "message_database.db" + name = "$dir/message_database.db", ) } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.ios.kt new file mode 100644 index 0000000..b6af43e --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/db/MessageDatabaseLock.ios.kt @@ -0,0 +1,14 @@ +package ru.fromchat.api.db + +import platform.Foundation.NSRecursiveLock + +private val lock = NSRecursiveLock() + +internal actual fun withMessageDatabaseLock(block: () -> T): T { + lock.lock() + try { + return block() + } finally { + lock.unlock() + } +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.ios.kt new file mode 100644 index 0000000..a532ae1 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/outbox/OutboxProcessor.ios.kt @@ -0,0 +1,14 @@ +package ru.fromchat.api.outbox + +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch + +/** iOS: drain outbox on enqueue and when the app returns to foreground (parity with WorkManager). */ +actual fun scheduleOutboxProcessing(instanceId: String) { + if (instanceId.trim().isEmpty()) return + MainScope().launch { + OutgoingMessageCoordinator.drainActiveInstanceOutbox() + } +} + +actual fun cancelOutboxProcessing(instanceId: String) = Unit diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.ios.kt new file mode 100644 index 0000000..90fad39 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheDirs.ios.kt @@ -0,0 +1,23 @@ +package ru.fromchat.core.cache + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSUserDomainMask + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun wipeFromChatCacheDirectory() { + withContext(Dispatchers.Default) { + val url = NSFileManager.defaultManager.URLForDirectory( + directory = NSCachesDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null, + ) ?: return@withContext + val path = url.path + "/fromchat" + NSFileManager.defaultManager.removeItemAtPath(path, error = null) + } +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.ios.kt new file mode 100644 index 0000000..ff77cd7 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/FromChatCacheGeneration.ios.kt @@ -0,0 +1,61 @@ +package ru.fromchat.core.cache + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSData +import platform.Foundation.NSFileManager +import platform.Foundation.NSUserDomainMask +import platform.Foundation.create +import platform.Foundation.writeToFile +import ru.fromchat.api.db.MessageDatabaseProvider + +private const val GENERATION_FILE = ".generation" + +@OptIn(ExperimentalForeignApi::class) +private fun fromChatRoot(): String { + val url = NSFileManager.defaultManager.URLForDirectory( + directory = NSCachesDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = true, + error = null, + ) ?: return "" + return url.path + "/fromchat" +} + +@OptIn(ExperimentalForeignApi::class) +private fun writeGenerationMarker(path: String) { + val bytes = "1\n".encodeToByteArray() + 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 ensureFromChatCacheGeneration() { + withContext(Dispatchers.Default) { + val root = fromChatRoot() + if (root.isEmpty()) return@withContext + val marker = "$root/$GENERATION_FILE" + if (NSFileManager.defaultManager.fileExistsAtPath(marker)) return@withContext + MessageDatabaseProvider.closeAndReset() + wipeFromChatCacheDirectory() + NSFileManager.defaultManager.createDirectoryAtPath(root, true, null, null) + writeGenerationMarker(marker) + } +} + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun writeFromChatCacheGeneration() { + withContext(Dispatchers.Default) { + val root = fromChatRoot() + if (root.isEmpty()) return@withContext + NSFileManager.defaultManager.createDirectoryAtPath(root, true, null, null) + writeGenerationMarker("$root/$GENERATION_FILE") + } +} 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 new file mode 100644 index 0000000..4a80479 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/core/cache/ResumableUploadStorage.ios.kt @@ -0,0 +1,146 @@ +package ru.fromchat.core.cache + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.get +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSData +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask +import platform.Foundation.create +import platform.Foundation.dataWithContentsOfURL +import platform.Foundation.writeToFile + +@OptIn(ExperimentalForeignApi::class) +private fun uploadDir(instanceId: String): String { + val url = NSFileManager.defaultManager.URLForDirectory( + directory = NSCachesDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = true, + error = null, + ) ?: return "" + val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_") + val path = url.path + "/fromchat/instances/$safe/uploads" + NSFileManager.defaultManager.createDirectoryAtPath(path, true, null, null) + return path +} + +@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" +} + +@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 sourcePath(instanceId: String, clientMessageId: String): String { + val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_") + return "${uploadDir(instanceId)}/$safeId.source" +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private fun readBytesAtPath(path: String): ByteArray? { + if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return null + val data = NSData.create(contentsOfFile = path) ?: return null + val length = data.length.toInt() + if (length == 0) return null + val bytesPtr = data.bytes ?: return null + val bytePtr = bytesPtr.reinterpret() + 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) { + val url = NSURL.URLWithString(fileUri) ?: throw OutboundFileUnavailableException("Invalid file URI") + val data = NSData.dataWithContentsOfURL(url) + ?: throw OutboundFileUnavailableException("Failed to read file from URI") + val length = data.length.toInt() + val bytesPtr = data.bytes ?: error("Empty file") + val bytePtr = bytesPtr.reinterpret() + ByteArray(length) { i -> bytePtr[i] } + } + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray) { + withContext(Dispatchers.Default) { + writeBytesAtPath(blobPath(instanceId, clientMessageId), bytes) + } +} + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? = + withContext(Dispatchers.Default) { + readBytesAtPath(blobPath(instanceId, clientMessageId)) + } + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String) { + withContext(Dispatchers.Default) { + writeBytesAtPath(cipherPath(instanceId, clientMessageId), json.encodeToByteArray()) + } +} + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun loadUploadTransportCipherJson(instanceId: String, clientMessageId: String): String? = + withContext(Dispatchers.Default) { + readBytesAtPath(cipherPath(instanceId, clientMessageId))?.decodeToString()?.takeIf { it.isNotBlank() } + } + +@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) + } +} + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun clearUploadSecretsOnly(instanceId: String, clientMessageId: String) { + withContext(Dispatchers.Default) { + NSFileManager.defaultManager.removeItemAtPath(blobPath(instanceId, clientMessageId), null) + NSFileManager.defaultManager.removeItemAtPath(cipherPath(instanceId, clientMessageId), null) + } +} 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 7b490bd..8daee97 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 @@ -40,7 +40,7 @@ actual object DmCrypto { actual suspend fun decryptEnvelope( ivB64: String, ciphertextB64: String, - mek: ByteArray + mek: ByteArray, ) = withContext(Dispatchers.Default) { val key = mek.require("MEK must be 32 bytes") { it.size == AES_KEY_SIZE @@ -62,6 +62,29 @@ actual object DmCrypto { } } + actual suspend fun decryptAesGcm( + ivB64: String, + ciphertext: ByteArray, + mek: ByteArray, + ) = withContext(Dispatchers.Default) { + val key = mek.require("MEK must be 32 bytes") { + it.size == AES_KEY_SIZE + } + val iv = Base64 + .decode(ivB64) + .require("IV must be 12 bytes") { + it.size == GCM_IV_SIZE + } + ciphertext.require("Ciphertext too short") { + it.size >= GCM_TAG_SIZE + } + try { + aesGcmDecrypt(key, iv, ciphertext) + } catch (e: Throwable) { + throw DmCiphertextCorruptedException(cause = e) + } + } + 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/ui/chat/AttachmentPickers.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt index 6ced6ce..fca4c1f 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt @@ -17,3 +17,5 @@ actual fun rememberFilePicker(onResult: (List) -> Unit): () -> Unit { } actual suspend fun getImageAspectRatio(uri: String): Float? = null + +actual suspend fun getImageDimensions(uri: String): Pair? = null diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.ios.kt new file mode 100644 index 0000000..2c5eb9f --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/LocalDecodedImageCache.ios.kt @@ -0,0 +1,94 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) + +package ru.fromchat.ui.chat + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.get +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.useContents +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Image +import org.jetbrains.skia.ImageInfo +import org.jetbrains.skia.SamplingMode +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSData +import platform.Foundation.create +import platform.UIKit.UIGraphicsBeginImageContextWithOptions +import platform.UIKit.UIGraphicsEndImageContext +import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext +import platform.UIKit.UIImage +import platform.UIKit.UIImageOrientation +import platform.UIKit.UIImagePNGRepresentation + +actual object PlatformDecodedBitmapCache { + private val cache = mutableMapOf() + + actual fun get(key: String): ImageBitmap? = cache[key] + + actual fun put(key: String, bitmap: ImageBitmap) { + cache[key] = bitmap + } + + actual fun remove(key: String) { + cache.remove(key) + } + + actual fun evictPrefix(prefix: String) { + val keys = cache.keys.filter { it.startsWith(prefix) } + for (key in keys) { + cache.remove(key) + } + } +} + +actual fun decodeLocalImageFile(absolutePath: String, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? { + val data = NSData.create(contentsOfFile = absolutePath) ?: return null + val uiImage = UIImage.imageWithData(data) ?: return null + val normalized = normalizeUiImageOrientation(uiImage) + val pngData = UIImagePNGRepresentation(normalized) ?: return null + return decodeImageBytes(nsDataToByteArray(pngData), reqWidthPx, reqHeightPx) +} + +actual fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? = + runCatching { + val image = Image.makeFromEncoded(bytes) ?: return@runCatching null + scaleSkiaImageToFitWithin(image, reqWidthPx, reqHeightPx) + }.getOrNull() + +private fun normalizeUiImageOrientation(image: UIImage): UIImage { + if (image.imageOrientation == UIImageOrientation.UIImageOrientationUp) return image + val width = image.size.useContents { width } + val height = image.size.useContents { height } + UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale) + image.drawInRect(CGRectMake(0.0, 0.0, width, height)) + val normalized = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return normalized ?: image +} + +private fun nsDataToByteArray(data: NSData): ByteArray { + val length = data.length.toInt() + if (length <= 0) return ByteArray(0) + val bytesPtr = data.bytes ?: return ByteArray(0) + val bytePtr = bytesPtr.reinterpret() + return ByteArray(length) { i -> bytePtr[i] } +} + +private fun scaleSkiaImageToFitWithin(image: Image, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap { + if (image.width <= reqWidthPx && image.height <= reqHeightPx) return image.toComposeImageBitmap() + val scale = minOf( + reqWidthPx.toFloat() / image.width.toFloat(), + reqHeightPx.toFloat() / image.height.toFloat(), + ) + if (scale >= 1f) return image.toComposeImageBitmap() + val dstW = (image.width * scale).toInt().coerceAtLeast(1) + val dstH = (image.height * scale).toInt().coerceAtLeast(1) + val dst = Bitmap() + dst.allocPixels(ImageInfo.makeN32Premul(dstW, dstH)) + val pixmap = dst.peekPixels() ?: return image.toComposeImageBitmap() + image.scalePixels(pixmap, SamplingMode.LINEAR, true) + return Image.makeFromBitmap(dst).toComposeImageBitmap() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cddcda3..bc07bfa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ agp = "9.1.1" androidx-activityCompose = "1.13.0" androidx-appcompat = "1.7.1" androidx-core-ktx = "1.18.0" +androidx-exifinterface = "1.3.7" coilCompose = "3.4.0" compose-multiplatform = "1.10.3" #noinspection NewerVersionAvailable @@ -47,6 +48,7 @@ livekitAndroidComposeComponents = "2.3.0" [libraries] androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } diff --git a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/ClipboardSupport.android.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/ClipboardSupport.android.kt new file mode 100644 index 0000000..f4097b3 --- /dev/null +++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/ClipboardSupport.android.kt @@ -0,0 +1,27 @@ +package com.pr0gramm3r101.utils + +import android.content.ClipData as AndroidClipData +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard + +actual fun Clipboard.toSupport(): SupportClipboardManager { + val clipboard = this + return object : SupportClipboardManager { + private var listener: ((String) -> Unit)? = null + + override suspend fun setText(string: String) { + val clipData = AndroidClipData.newPlainText("text", string) + clipboard.setClipEntry(ClipEntry(clipData)) + listener?.invoke(string) + } + + override suspend fun getText(): String? { + val entry = clipboard.getClipEntry() ?: return null + return entry.clipData?.getItemAt(0)?.text?.toString() + } + + override fun setTextListener(listener: (String) -> Unit) { + this.listener = listener + } + } +} 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 89b220c..f0688cd 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 @@ -7,7 +7,9 @@ internal actual fun expectExists(path: String): Boolean = File(path).exists() internal actual fun expectWriteBytes(path: String, bytes: ByteArray) { - File(path).writeBytes(bytes) + val file = File(path) + file.parentFile?.mkdirs() + file.writeBytes(bytes) } internal actual fun expectDelete(path: String) { diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt index 3abb12e..e218dee 100644 --- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt @@ -85,20 +85,7 @@ val LocalSupportClipboardManager = compositionLocalOf { val supportClipboardManagerImpl @Composable get() = LocalClipboard().toSupport() -inline fun Clipboard.toSupport() = object : SupportClipboardManager { - private var listener: ((String) -> Unit)? = null - - override suspend fun setText(string: String) { - setText(string) - listener?.invoke(string) - } - - override suspend fun getText() = this@toSupport.getClipEntry()?.toString() ?: "" - - override fun setTextListener(listener: (String) -> Unit) { - this.listener = listener - } -} +expect fun Clipboard.toSupport(): SupportClipboardManager fun String.toAnnotatedString() = AnnotatedString(this) diff --git a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/ClipboardSupport.ios.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/ClipboardSupport.ios.kt new file mode 100644 index 0000000..0451ac5 --- /dev/null +++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/ClipboardSupport.ios.kt @@ -0,0 +1,22 @@ +package com.pr0gramm3r101.utils + +import androidx.compose.ui.platform.Clipboard +import platform.UIKit.UIPasteboard + +actual fun Clipboard.toSupport(): SupportClipboardManager { + return object : SupportClipboardManager { + private var listener: ((String) -> Unit)? = null + + override suspend fun setText(string: String) { + UIPasteboard.generalPasteboard.string = string + listener?.invoke(string) + } + + override suspend fun getText(): String? = + UIPasteboard.generalPasteboard.string + + override fun setTextListener(listener: (String) -> Unit) { + this.listener = listener + } + } +} 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 79a5676..f3f6304 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 @@ -19,6 +19,10 @@ internal actual fun expectExists(path: String): Boolean = @OptIn(ExperimentalForeignApi::class) internal actual fun expectWriteBytes(path: String, bytes: ByteArray) { + val parent = path.substringBeforeLast('/', missingDelimiterValue = "") + if (parent.isNotEmpty()) { + NSFileManager.defaultManager.createDirectoryAtPath(parent, true, null, null) + } val nsData = bytes.usePinned { pinned -> NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) }