mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 19:45:04 +03:00
Make a robust sending system
This commit is contained in:
@@ -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(
|
||||
*(
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name="ru.fromchat.calls.CallForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="camera|microphone" />
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -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)
|
||||
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit) =
|
||||
HttpClient(OkHttp) {
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 30_000
|
||||
connectTimeoutMillis = 5_000
|
||||
socketTimeoutMillis = 30_000
|
||||
}
|
||||
block()
|
||||
}
|
||||
+15
-281
@@ -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<AttachmentUploadProgress>(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<AttachmentUploadProgress> = AttachmentUploadEvents.flow
|
||||
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow
|
||||
|
||||
actual fun enqueue(job: AttachmentUploadJob) {
|
||||
AttachmentUploadEvents.flow.tryEmit(
|
||||
AttachmentUploadProgress.Pending(
|
||||
jobId = job.jobId,
|
||||
filename = job.filename
|
||||
)
|
||||
)
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<DmAttachmentUploadWorker>()
|
||||
.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<ByteArray, TransportCiphertext>? {
|
||||
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), "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-2
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.fromchat.api.db
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
internal actual fun <T> withMessageDatabaseLock(block: () -> T): T = synchronized(lock, block)
|
||||
+72
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<OutboxSendWorker>()
|
||||
.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")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+12
@@ -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()
|
||||
}
|
||||
}
|
||||
Vendored
+30
@@ -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")
|
||||
}
|
||||
}
|
||||
+108
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Int, Int>? {
|
||||
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
|
||||
}
|
||||
|
||||
+144
@@ -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<String, ImageBitmap> = object : LruCache<String, ImageBitmap>(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<Int, Int> =
|
||||
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)
|
||||
}
|
||||
@@ -64,16 +64,25 @@
|
||||
<string name="notif_call_channel_name">Активный звонок</string>
|
||||
<string name="notif_call_ongoing_title">Видеозвонок</string>
|
||||
<string name="notif_call_ongoing_text">Камера и микрофон остаются включёнными, пока вы вне приложения</string>
|
||||
<string name="notif_media_upload_channel_name">Загрузка медиа</string>
|
||||
<string name="notif_media_upload_title">Отправка вложения</string>
|
||||
<string name="notif_media_upload_text">Загрузка продолжается в фоне</string>
|
||||
<string name="message_sender_you">Вы</string>
|
||||
<string name="user_fallback">Человек %1$d</string>
|
||||
<string name="message_corrupted">Это сообщение не удалось показать.</string>
|
||||
<string name="message_edited_suffix">(изменено)</string>
|
||||
<string name="message_replying_to">Ответ %1$s</string>
|
||||
<string name="message_corrupted_short">Сообщение не показывается</string>
|
||||
<string name="attachment_image_load_failed">Не удалось загрузить</string>
|
||||
<string name="attachment_retry">Повторить</string>
|
||||
<string name="cd_attachment_retry">Повторить загрузку изображения</string>
|
||||
<string name="message_editing_title">Правка сообщения</string>
|
||||
<string name="action_reply">Ответить</string>
|
||||
<string name="action_edit">Изменить</string>
|
||||
<string name="action_delete">Удалить</string>
|
||||
<string name="action_copy">Копировать</string>
|
||||
<string name="action_cancel_send">Отменить</string>
|
||||
<string name="notif_media_upload_progress">%1$d%% · %2$s</string>
|
||||
<string name="action_save">Сохранить</string>
|
||||
<string name="cd_close">Закрыть</string>
|
||||
<string name="cd_remove">Убрать</string>
|
||||
@@ -152,6 +161,16 @@
|
||||
<string name="server_config_action_reset">Сбросить</string>
|
||||
<string name="server_config_action_reset_confirm_title">Сбросить настройки?</string>
|
||||
<string name="server_config_action_reset_confirm_body">Будут восстановлены адрес сервера и порты по умолчанию.</string>
|
||||
<string name="server_config_unsupported_no_instance_id">Сервер не предоставляет корректный ID экземпляра. Подключение невозможно.</string>
|
||||
<string name="server_config_snackbar_timeout">Сервер не ответил вовремя. Повторите попытку.</string>
|
||||
<string name="server_config_checking">Проверка…</string>
|
||||
<string name="server_config_session_logged_out_no_instance">Сессия завершена: не удалось определить ID экземпляра сервера.</string>
|
||||
|
||||
<string name="action_wipe_local_cache_title">Очистить локальные данные</string>
|
||||
<string name="action_wipe_local_cache_supporting">Удаляет кэш сообщений, медиа и отложенные отправки на этом устройстве. Аккаунт на сервере не затрагивается.</string>
|
||||
<string name="action_wipe_local_cache_confirm_title">Очистить локальные данные?</string>
|
||||
<string name="action_wipe_local_cache_confirm_body">Будут удалены офлайн-сообщения и загрузки для всех экземпляров сервера на этом устройстве. Неотправленные сообщения из очереди будут потеряны.</string>
|
||||
<string name="action_wipe_local_cache_done">Локальные данные очищены</string>
|
||||
<string name="save_continue">Сохранить и продолжить</string>
|
||||
<string name="change_server">Сменить сервер</string>
|
||||
<string name="change_server_d">Подключиться к альтернативному серверу FromChat и выйти из аккаунта.</string>
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
<string name="notif_call_channel_name">Ongoing call</string>
|
||||
<string name="notif_call_ongoing_title">Video call</string>
|
||||
<string name="notif_call_ongoing_text">Camera and microphone stay active while you’re away</string>
|
||||
<string name="notif_media_upload_channel_name">Media upload</string>
|
||||
<string name="notif_media_upload_title">Sending attachment</string>
|
||||
<string name="notif_media_upload_text">Upload continues in the background</string>
|
||||
|
||||
<!-- Messages -->
|
||||
<string name="message_sender_you">You</string>
|
||||
@@ -83,13 +86,19 @@
|
||||
<string name="message_edited_suffix">(edited)</string>
|
||||
<string name="message_replying_to">Reply to %1$s</string>
|
||||
<string name="message_corrupted_short">Can’t show this message</string>
|
||||
<string name="attachment_image_load_failed">Failed to load</string>
|
||||
<string name="attachment_retry">Retry</string>
|
||||
<string name="cd_attachment_retry">Retry loading image</string>
|
||||
<string name="message_editing_title">Edit message</string>
|
||||
|
||||
<!-- Message actions -->
|
||||
<string name="action_reply">Reply</string>
|
||||
<string name="action_edit">Edit</string>
|
||||
<string name="action_delete">Delete</string>
|
||||
<string name="action_copy">Copy</string>
|
||||
<string name="action_cancel_send">Cancel</string>
|
||||
<string name="action_save">Save</string>
|
||||
<string name="notif_media_upload_progress">%1$d%% · %2$s</string>
|
||||
|
||||
<!-- Chat input -->
|
||||
<string name="cd_close">Close</string>
|
||||
@@ -179,6 +188,17 @@
|
||||
<string name="server_config_action_reset">Reset</string>
|
||||
<string name="server_config_action_reset_confirm_title">Reset to defaults?</string>
|
||||
<string name="server_config_action_reset_confirm_body">This restores the default server and port settings.</string>
|
||||
<string name="server_config_unsupported_no_instance_id">This server does not provide a valid instance ID. Cannot connect.</string>
|
||||
<string name="server_config_snackbar_timeout">Server did not respond in time. Try again.</string>
|
||||
<string name="server_config_checking">Checking…</string>
|
||||
<string name="server_config_session_logged_out_no_instance">Session ended: server instance ID could not be resolved.</string>
|
||||
|
||||
<!-- Local offline data wipe (Settings → Server tools) -->
|
||||
<string name="action_wipe_local_cache_title">Clear local data</string>
|
||||
<string name="action_wipe_local_cache_supporting">Removes cached messages, media, and pending sends on this device. Your account on the server is not affected.</string>
|
||||
<string name="action_wipe_local_cache_confirm_title">Clear local data?</string>
|
||||
<string name="action_wipe_local_cache_confirm_body">All offline messages and downloads for every server instance on this device will be deleted. Unsent messages in the outbox will be lost.</string>
|
||||
<string name="action_wipe_local_cache_done">Local data cleared</string>
|
||||
<string name="save_continue">Save and continue</string>
|
||||
|
||||
<!-- Settings -->
|
||||
|
||||
@@ -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<ByteArray>()
|
||||
onProgress?.invoke(100)
|
||||
partialPath?.let { PlatformFileSystem.delete(it) }
|
||||
bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readDownloadBody(
|
||||
response: HttpResponse,
|
||||
prefix: ByteArray,
|
||||
partialPath: String?,
|
||||
onProgress: ((percent: Int) -> Unit)?,
|
||||
): ByteArray {
|
||||
val channel = response.bodyAsChannel()
|
||||
var buffer = prefix
|
||||
var received = prefix.size
|
||||
val totalBytes = responseTotalBytes(response, received)
|
||||
|
||||
while (!channel.isClosedForRead) {
|
||||
val packet = channel.readRemaining(16 * 1024)
|
||||
if (packet.isEmpty) break
|
||||
val chunk = packet.readBytes()
|
||||
if (chunk.isEmpty()) continue
|
||||
buffer = buffer + chunk
|
||||
received += chunk.size
|
||||
partialPath?.let { PlatformFileSystem.writeBytes(it, buffer) }
|
||||
onProgress?.invoke(
|
||||
if (totalBytes != null && totalBytes > 0) {
|
||||
percentForBytes(received, totalBytes)
|
||||
} else {
|
||||
(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 {
|
||||
|
||||
@@ -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<AttachmentDownloadProgress>(extraBufferCapacity = 64)
|
||||
val progressFlow: SharedFlow<AttachmentDownloadProgress> = _progressFlow
|
||||
|
||||
private val _progressPercentByKey = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val progressPercentByKey: StateFlow<Map<String, Int>> = _progressPercentByKey.asStateFlow()
|
||||
|
||||
private val _failedKeys = MutableStateFlow<Set<String>>(emptySet())
|
||||
val failedKeys: StateFlow<Set<String>> = _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 }
|
||||
}
|
||||
@@ -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<AttachmentUploadProgress>(extraBufferCapacity = 64)
|
||||
val progressFlow: SharedFlow<AttachmentUploadProgress> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Message>): List<Message> {
|
||||
if (messages.size <= 1) return messages
|
||||
val (pending, confirmed) = messages.partition { it.id < 0 }
|
||||
val comparator = compareBy<Message>(
|
||||
{ messageSortEpochMillis(it) },
|
||||
{ it.id.toLong() },
|
||||
)
|
||||
return confirmed.sortedWith(comparator) + pending.sortedWith(comparator)
|
||||
}
|
||||
|
||||
internal fun messageSortEpochMillis(message: Message): Long =
|
||||
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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<Int, UserProfile> = 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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<String>? = null,
|
||||
@SerialName("fileAspectRatios") val fileAspectRatios: List<Float>? = null,
|
||||
@SerialName("fileSizes") val fileSizes: List<Long>? = null,
|
||||
@SerialName("fileDimensions") val fileDimensions: List<List<Int>>? = 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<String>? = null,
|
||||
val fileAspectRatios: List<Float>? = null,
|
||||
val fileSizes: List<Long>? = null,
|
||||
val fileDimensions: List<Pair<Int, Int>>? = 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<Int, Int> {
|
||||
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<PersistedDmMessagePayload>(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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<List<Message>> =
|
||||
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<Message> {
|
||||
return loadMessages(conversationIdForPublic())
|
||||
}
|
||||
suspend fun loadPublicMessages(): List<Message> =
|
||||
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<Message> {
|
||||
return loadRecentMessages(conversationIdForPublic(), limit)
|
||||
}
|
||||
suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
|
||||
loadRecentMessages(conversationIdForPublic(), limit)
|
||||
|
||||
suspend fun replacePublicMessages(messages: List<Message>) {
|
||||
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<Message> {
|
||||
return loadMessages(conversationIdForDm(otherUserId))
|
||||
}
|
||||
suspend fun loadDmMessages(otherUserId: Int): List<Message> =
|
||||
loadMessages(conversationIdForDm(otherUserId))
|
||||
|
||||
suspend fun clearDmMessages(otherUserId: Int) {
|
||||
clearConversationMessages(conversationIdForDm(otherUserId))
|
||||
@@ -118,22 +95,50 @@ object MessageCacheStore {
|
||||
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
|
||||
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<Message>,
|
||||
messages: List<Message>,
|
||||
): List<Message> {
|
||||
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<Message> =
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(conversationId)
|
||||
.executeAsList()
|
||||
.map { row: DbMessage -> row.toAppMessage() }
|
||||
}
|
||||
|
||||
private suspend fun loadRecentMessages(conversationId: String, limit: Long): List<Message> =
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries
|
||||
.selectRecentMessagesByConversation(conversationId, limit)
|
||||
.executeAsList()
|
||||
.map { row: DbMessage -> row.toAppMessage() }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
private suspend fun loadPendingMessages(conversationId: String): List<Message> =
|
||||
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<Message>) {
|
||||
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<DmConversation>) {
|
||||
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<CachedConversation> =
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message>,
|
||||
conversationId: String,
|
||||
): List<Message> {
|
||||
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<DmAttachmentOutboxPayload>(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<Message>,
|
||||
after: List<Message>,
|
||||
) {
|
||||
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<Message>): List<Message> {
|
||||
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<Message>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = setOf(
|
||||
"server_binding",
|
||||
"instance_registry",
|
||||
"conversation",
|
||||
"message",
|
||||
"attachment",
|
||||
"outbox",
|
||||
"profile_cache",
|
||||
)
|
||||
|
||||
val tableCreateSql: Map<String, String> = 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<String, Map<String, String>> = 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<String> = setOf("message", "conversation", "attachment")
|
||||
|
||||
val requiredIndexes: Map<String, String> = mapOf(
|
||||
"message_instance_conversation_index" to
|
||||
"CREATE INDEX IF NOT EXISTS message_instance_conversation_index ON message(instanceId, conversationId, timestamp)",
|
||||
)
|
||||
|
||||
val obsoleteIndexes: Set<String> = setOf("message_conversation_index")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package ru.fromchat.api.db
|
||||
|
||||
internal expect fun <T> withMessageDatabaseLock(block: () -> T): T
|
||||
@@ -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 <T> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> = emptyList(),
|
||||
val partitionRebuilds: List<PartitionedTableRebuild> = emptyList(),
|
||||
val addColumns: List<ColumnAdd> = emptyList(),
|
||||
val ensureIndexes: List<Pair<String, String>> = emptyList(),
|
||||
val dropIndexes: Set<String> = 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<String>()
|
||||
var partitionRebuilds = mutableListOf<PartitionedTableRebuild>()
|
||||
var addColumns = mutableListOf<ColumnAdd>()
|
||||
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<String>): 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<String>): 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<String> {
|
||||
val names = mutableSetOf<String>()
|
||||
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<String> {
|
||||
val names = mutableSetOf<String>()
|
||||
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
|
||||
@@ -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<List<Message>> =
|
||||
MessageCacheStore.observeMessages(activeInstance(), conversationId)
|
||||
|
||||
fun observePublicMessages(): Flow<List<Message>> =
|
||||
observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID))
|
||||
|
||||
fun observeDmMessages(otherUserId: Int): Flow<List<Message>> =
|
||||
observeMessages(conversationIdForDm(otherUserId))
|
||||
|
||||
suspend fun loadPublicMessages(): List<Message> = MessageCacheStore.loadPublicMessages()
|
||||
|
||||
suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
|
||||
MessageCacheStore.loadRecentPublicMessages(limit)
|
||||
|
||||
suspend fun replacePublicMessages(messages: List<Message>) =
|
||||
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<Message> =
|
||||
MessageCacheStore.loadDmMessages(otherUserId)
|
||||
|
||||
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) =
|
||||
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<ru.fromchat.api.DmConversation>) =
|
||||
MessageCacheStore.replaceDmConversations(conversations)
|
||||
|
||||
suspend fun loadCachedDmConversations(): List<CachedConversation> =
|
||||
MessageCacheStore.loadCachedDmConversations()
|
||||
|
||||
suspend fun clearAllCache() = MessageCacheStore.clearAll()
|
||||
}
|
||||
@@ -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<Int, UserProfile> =
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<DmAttachmentOutboxPayload>(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<String>,
|
||||
) {
|
||||
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<ByteArray, TransportCiphertext>? {
|
||||
val blob = loadEncryptedUploadBlob(instanceId, clientMessageId) ?: return null
|
||||
val raw = loadUploadTransportCipherJson(instanceId, clientMessageId) ?: return null
|
||||
return runCatching {
|
||||
val stored = json.decodeFromString<StoredTransportCipher>(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 }
|
||||
}
|
||||
}
|
||||
@@ -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<SendDmFile> = emptyList(),
|
||||
val uploadedFileIds: List<String> = 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 = "",
|
||||
)
|
||||
@@ -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)
|
||||
+307
@@ -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<ru.fromchat.api.SendDmFile> = emptyList(),
|
||||
uploadedFileIds: List<String> = 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<DmAttachmentOutboxPayload>(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<PublicOutboxPayload>(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<DmOutboxPayload>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<DeviceSessionInfo>? = runBlocking {
|
||||
|
||||
@@ -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<String> = _activeInstanceId.asStateFlow()
|
||||
|
||||
private val _activeUserId = MutableStateFlow<Int?>(null)
|
||||
val activeUserId: StateFlow<Int?> = _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
|
||||
}
|
||||
}
|
||||
@@ -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<Message>,
|
||||
currentUserId: Int?,
|
||||
dmPeerUserId: Int? = dmOtherUserIdFromConversationId(conversationId),
|
||||
): List<Message> = 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<Message>,
|
||||
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)
|
||||
}
|
||||
@@ -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()
|
||||
+8
@@ -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()
|
||||
Vendored
+16
@@ -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
|
||||
}
|
||||
+31
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+62
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 -> {}
|
||||
|
||||
@@ -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<String?>,
|
||||
)
|
||||
|
||||
private val waiting = mutableListOf<Pending>()
|
||||
private val keyToDeferred = mutableMapOf<String, CompletableDeferred<String?>>()
|
||||
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<String?>()
|
||||
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<Pending>()
|
||||
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> { pending ->
|
||||
if (AttachmentDownloadVisibility.isPrioritized(pending.messageId)) 0 else 1
|
||||
}.thenBy { it.enqueuedAt },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Set<Int>>(emptySet())
|
||||
|
||||
val visibleMessageIds: StateFlow<Set<Int>> = _visibleMessageIds.asStateFlow()
|
||||
|
||||
fun setVisibleMessageIds(ids: Set<Int>) {
|
||||
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<Message>,
|
||||
extraMessageId: Int? = null,
|
||||
): Set<Int> {
|
||||
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
|
||||
}
|
||||
@@ -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<Float>?,
|
||||
fileDimensions: List<Pair<Int, Int>>?,
|
||||
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
|
||||
}
|
||||
@@ -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<String, Any?>) = log("UPLOAD", message, fields)
|
||||
|
||||
fun download(message: String, vararg fields: Pair<String, Any?>) = log("DOWNLOAD", message, fields)
|
||||
|
||||
fun diskCache(message: String, vararg fields: Pair<String, Any?>) = log("DISK", message, fields)
|
||||
|
||||
fun bitmapCache(message: String, vararg fields: Pair<String, Any?>) = log("BITMAP", message, fields)
|
||||
|
||||
fun tileLoad(message: String, vararg fields: Pair<String, Any?>) = log("TILE", message, fields)
|
||||
|
||||
fun persist(message: String, vararg fields: Pair<String, Any?>) = 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<out Pair<String, Any?>>) {
|
||||
val suffix = if (fields.isEmpty()) {
|
||||
""
|
||||
} else {
|
||||
" | " + fields.joinToString(" ") { (k, v) -> "$k=$v" }
|
||||
}
|
||||
Logger.d(TAG, "[$subsystem] $message$suffix")
|
||||
}
|
||||
}
|
||||
@@ -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<Int, Int>?
|
||||
|
||||
@@ -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<ChatPreviewDecodeSize?>(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<ImageBitmap?>(
|
||||
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<Int?>(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<Float>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Message>) {
|
||||
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<Message> {
|
||||
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<Message>) {
|
||||
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) {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Pair<Message, Int>?>(null) }
|
||||
var isImageClosing by remember { mutableStateOf(false) }
|
||||
val imageThumbBounds = remember { mutableStateMapOf<String, Rect>() }
|
||||
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,
|
||||
|
||||
@@ -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<String, String>()
|
||||
|
||||
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<String> = 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<String, Int> = 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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Message>): List<Message> {
|
||||
if (messages.size <= 1) return messages
|
||||
val order = ArrayList<String>(messages.size)
|
||||
val byKey = LinkedHashMap<String, Message>(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<Message>,
|
||||
currentUserId: Int?,
|
||||
): List<Message> {
|
||||
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)
|
||||
}
|
||||
@@ -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<Message>,
|
||||
dbMessages: List<Message>,
|
||||
): List<Message> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Pair<Int, String>>()
|
||||
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<String>?,
|
||||
val aspectRatios: List<Float>?,
|
||||
val fileSizes: List<Long>?,
|
||||
val fileDimensions: List<Pair<Int, Int>>?
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<Int, DmPanel>()
|
||||
private var supervisorJob = SupervisorJob()
|
||||
private var panelScope: CoroutineScope = CoroutineScope(supervisorJob + Dispatchers.Main.immediate)
|
||||
|
||||
fun getOrCreate(
|
||||
otherUserId: Int,
|
||||
scope: CoroutineScope
|
||||
): DmPanel {
|
||||
private val panels = mutableMapOf<Int, DmPanel>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ServerProbeResult?>(null) }
|
||||
var lastProbedConfig by remember { mutableStateOf<ServerConfigData?>(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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,8 +22,9 @@ actual fun createPlatformHttpClient(
|
||||
}
|
||||
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 30000
|
||||
connectTimeoutMillis = 30000
|
||||
requestTimeoutMillis = 30_000
|
||||
connectTimeoutMillis = 5_000
|
||||
socketTimeoutMillis = 30_000
|
||||
}
|
||||
|
||||
block(this)
|
||||
|
||||
@@ -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<AttachmentUploadProgress>(extraBufferCapacity = 64)
|
||||
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = _progressFlow
|
||||
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = 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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.fromchat.api.db
|
||||
|
||||
import platform.Foundation.NSRecursiveLock
|
||||
|
||||
private val lock = NSRecursiveLock()
|
||||
|
||||
internal actual fun <T> withMessageDatabaseLock(block: () -> T): T {
|
||||
lock.lock()
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
+61
@@ -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")
|
||||
}
|
||||
}
|
||||
+146
@@ -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<ByteVar>()
|
||||
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<ByteVar>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -17,3 +17,5 @@ actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
|
||||
}
|
||||
|
||||
actual suspend fun getImageAspectRatio(uri: String): Float? = null
|
||||
|
||||
actual suspend fun getImageDimensions(uri: String): Pair<Int, Int>? = null
|
||||
|
||||
@@ -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<String, ImageBitmap>()
|
||||
|
||||
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<ByteVar>()
|
||||
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()
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
+27
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user