Make a robust sending system

This commit is contained in:
2026-05-19 15:45:21 +03:00
Unverified
parent 644499dd2c
commit 1a93145ee5
103 changed files with 7434 additions and 1442 deletions
+5
View File
@@ -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()
}
@@ -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), "")
}
}
@@ -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)
@@ -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"
}
}
@@ -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()
}
}
@@ -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")
}
}
@@ -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
}
@@ -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 youre 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">Cant 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)
@@ -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()
@@ -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()
@@ -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
}
@@ -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()
}
@@ -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,
/** 0100 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
@@ -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)
}
}
@@ -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")
}
}
@@ -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()
}
+2
View File
@@ -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" }
@@ -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
}
}
}
@@ -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