mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-24 12:05:04 +03:00
Make a robust sending system
This commit is contained in:
@@ -83,6 +83,7 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
implementation(libs.androidx.exifinterface)
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
implementation(libs.firebase.messaging)
|
implementation(libs.firebase.messaging)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
@@ -117,6 +118,10 @@ compose.resources {
|
|||||||
generateResClass = auto
|
generateResClass = auto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach {
|
||||||
|
dependsOn("generateResourceAccessorsForCommonMain")
|
||||||
|
}
|
||||||
|
|
||||||
tasks.register("generateResourceAccessors") {
|
tasks.register("generateResourceAccessors") {
|
||||||
dependsOn(
|
dependsOn(
|
||||||
*(
|
*(
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
<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_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<service
|
<service
|
||||||
android:name="ru.fromchat.calls.CallForegroundService"
|
android:name="ru.fromchat.calls.CallForegroundService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="camera|microphone" />
|
android:foregroundServiceType="camera|microphone" />
|
||||||
|
<service
|
||||||
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
tools:node="merge" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -3,5 +3,14 @@ package ru.fromchat.api
|
|||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.HttpClientConfig
|
import io.ktor.client.HttpClientConfig
|
||||||
import io.ktor.client.engine.okhttp.OkHttp
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
|
|
||||||
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp, block)
|
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit) =
|
||||||
|
HttpClient(OkHttp) {
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = 30_000
|
||||||
|
connectTimeoutMillis = 5_000
|
||||||
|
socketTimeoutMillis = 30_000
|
||||||
|
}
|
||||||
|
block()
|
||||||
|
}
|
||||||
+15
-281
@@ -1,294 +1,28 @@
|
|||||||
package ru.fromchat.api
|
package ru.fromchat.api
|
||||||
|
|
||||||
import android.content.Context
|
import kotlinx.coroutines.MainScope
|
||||||
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.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.launch
|
||||||
import ru.fromchat.crypto.transport.TransportCiphertext
|
import ru.fromchat.api.outbox.OutgoingMessageCoordinator
|
||||||
import ru.fromchat.crypto.transport.TransportCrypto
|
import ru.fromchat.api.outbox.scheduleOutboxProcessing
|
||||||
|
import ru.fromchat.core.cache.CacheContext
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual object AttachmentUploadQueue {
|
actual object AttachmentUploadQueue {
|
||||||
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadEvents.flow
|
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow
|
||||||
|
|
||||||
actual fun enqueue(job: AttachmentUploadJob) {
|
actual fun enqueue(job: AttachmentUploadJob) {
|
||||||
AttachmentUploadEvents.flow.tryEmit(
|
scheduleOutboxProcessing(CacheContext.activeInstanceId.value.trim())
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun cancel(jobId: String) {
|
actual fun cancel(jobId: String) {
|
||||||
val ctx = UtilsLibrary.context
|
val instanceId = CacheContext.activeInstanceId.value.trim()
|
||||||
dmBlobCacheFile(ctx, jobId).delete()
|
if (instanceId.isEmpty()) return
|
||||||
runBlocking {
|
MainScope().launch {
|
||||||
settings.putString(dmTransportCipherPrefsKey(jobId), "")
|
val row = ru.fromchat.api.db.MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
}
|
.selectOutboxItem(instanceId, jobId.trim())
|
||||||
WorkManager.getInstance(ctx).cancelUniqueWork(uniqueWorkName(jobId))
|
.executeAsOneOrNull()
|
||||||
}
|
val conversationId = row?.conversationId ?: return@launch
|
||||||
|
OutgoingMessageCoordinator.cancelOutboundMessage(jobId, conversationId)
|
||||||
private fun uniqueWorkName(jobId: String): String = "dm-attachment-upload-$jobId"
|
|
||||||
}
|
|
||||||
|
|
||||||
class DmAttachmentUploadWorker(
|
|
||||||
appContext: Context,
|
|
||||||
params: WorkerParameters
|
|
||||||
) : CoroutineWorker(appContext, params) {
|
|
||||||
companion object {
|
|
||||||
const val KEY_JOB_ID = "job_id"
|
|
||||||
const val KEY_FILE_URI = "file_uri"
|
|
||||||
const val KEY_FILENAME = "filename"
|
|
||||||
const val KEY_RECIPIENT_ID = "recipient_id"
|
|
||||||
const val KEY_PLAINTEXT = "plaintext"
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
|
||||||
val jobId = inputData.getString(KEY_JOB_ID)
|
|
||||||
?: return Result.failure()
|
|
||||||
val fileUri = inputData.getString(KEY_FILE_URI)
|
|
||||||
?: return Result.failure()
|
|
||||||
val filename = inputData.getString(KEY_FILENAME)
|
|
||||||
?: "file"
|
|
||||||
val recipientId = inputData.getInt(KEY_RECIPIENT_ID, -1)
|
|
||||||
val plaintext = inputData.getString(KEY_PLAINTEXT)?.trim().orEmpty()
|
|
||||||
|
|
||||||
if (recipientId <= 0) return Result.failure()
|
|
||||||
if (plaintext.isBlank()) return Result.failure()
|
|
||||||
|
|
||||||
return runCatching {
|
|
||||||
emitProgress(jobId, 0)
|
|
||||||
|
|
||||||
val prepared = loadPreparedTransportAndBlob(applicationContext, jobId)
|
|
||||||
val encryptedBlob: ByteArray
|
|
||||||
val msgCipher: TransportCiphertext
|
|
||||||
|
|
||||||
if (prepared != null) {
|
|
||||||
encryptedBlob = prepared.first
|
|
||||||
msgCipher = prepared.second
|
|
||||||
} else {
|
|
||||||
val staleUploadId = settings.getString(uploadIdKey(jobId), "").ifBlank { null }
|
|
||||||
if (staleUploadId != null) {
|
|
||||||
runCatching { ApiClient.abortDmUpload(staleUploadId) }
|
|
||||||
settings.putString(uploadIdKey(jobId), "")
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportKey = ApiClient.getTransportPublicKey()
|
|
||||||
val (freshCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret(
|
|
||||||
plaintext = plaintext,
|
|
||||||
transportPublicKeyB64 = transportKey.publicKeyB64
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
val bytes = applicationContext.contentResolver.openInputStream(Uri.parse(fileUri))?.use { it.readBytes() }
|
|
||||||
?: error("Failed to read file from URI")
|
|
||||||
val blob = TransportCrypto.encryptFileForTransport(
|
|
||||||
fileBytes = bytes,
|
|
||||||
transportPublicKeyB64 = transportKey.publicKeyB64,
|
|
||||||
ephemeralSecretKey = ephemeralSecret
|
|
||||||
)
|
|
||||||
encryptedBlob = blob
|
|
||||||
msgCipher = freshCipher
|
|
||||||
savePreparedTransportAndBlob(applicationContext, jobId, encryptedBlob, msgCipher)
|
|
||||||
} finally {
|
|
||||||
ephemeralSecret.fill(0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) {
|
|
||||||
sendInline(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher)
|
|
||||||
} else {
|
|
||||||
sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPreparedTransportAndBlob(applicationContext, jobId)
|
|
||||||
clearResumableState(jobId)
|
|
||||||
AttachmentUploadEvents.flow.tryEmit(AttachmentUploadProgress.Success(jobId))
|
|
||||||
Result.success()
|
|
||||||
}.getOrElse { error ->
|
|
||||||
AttachmentUploadEvents.flow.tryEmit(
|
|
||||||
AttachmentUploadProgress.Failed(
|
|
||||||
jobId = jobId,
|
|
||||||
error = error.message ?: "Upload failed"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (runAttemptCount >= 5) Result.failure() else Result.retry()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun sendInline(
|
|
||||||
jobId: String,
|
|
||||||
recipientId: Int,
|
|
||||||
plaintext: String,
|
|
||||||
filename: String,
|
|
||||||
encryptedBlob: ByteArray,
|
|
||||||
msgCipher: TransportCiphertext
|
|
||||||
) {
|
|
||||||
val file = SendDmFile(
|
|
||||||
encryptedFileDataB64 = Base64.encode(encryptedBlob),
|
|
||||||
filename = filename,
|
|
||||||
fileSize = encryptedBlob.size.toLong()
|
|
||||||
)
|
|
||||||
ApiClient.sendDm(
|
|
||||||
recipientId = recipientId,
|
|
||||||
plaintext = plaintext,
|
|
||||||
clientMessageId = jobId,
|
|
||||||
transportFiles = listOf(file),
|
|
||||||
preparedTransport = msgCipher
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun sendResumable(
|
|
||||||
jobId: String,
|
|
||||||
recipientId: Int,
|
|
||||||
plaintext: String,
|
|
||||||
filename: String,
|
|
||||||
encryptedBlob: ByteArray,
|
|
||||||
msgCipher: TransportCiphertext
|
|
||||||
) {
|
|
||||||
val uploadId = settings.getString(uploadIdKey(jobId), "").ifBlank {
|
|
||||||
val init = ApiClient.initDmUpload(
|
|
||||||
filename = filename,
|
|
||||||
totalSize = encryptedBlob.size.toLong(),
|
|
||||||
recipientId = recipientId,
|
|
||||||
chunkSize = DEFAULT_CHUNK_SIZE
|
|
||||||
)
|
|
||||||
settings.putString(uploadIdKey(jobId), init.uploadId)
|
|
||||||
init.uploadId
|
|
||||||
}
|
|
||||||
|
|
||||||
var offset = ApiClient.getDmUploadStatus(uploadId).offset.toInt()
|
|
||||||
while (offset < encryptedBlob.size) {
|
|
||||||
val nextOffset = minOf(offset + DEFAULT_CHUNK_SIZE, encryptedBlob.size)
|
|
||||||
val chunk = encryptedBlob.copyOfRange(offset, nextOffset)
|
|
||||||
ApiClient.uploadDmChunk(
|
|
||||||
uploadId = uploadId,
|
|
||||||
offset = offset.toLong(),
|
|
||||||
dataB64 = Base64.encode(chunk)
|
|
||||||
)
|
|
||||||
offset = nextOffset
|
|
||||||
emitProgress(jobId, ((offset.toDouble() / encryptedBlob.size.toDouble()) * 100.0).toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
val completed = ApiClient.completeDmUpload(uploadId)
|
|
||||||
ApiClient.sendDm(
|
|
||||||
recipientId = recipientId,
|
|
||||||
plaintext = plaintext,
|
|
||||||
clientMessageId = jobId,
|
|
||||||
uploadedFileIds = listOf(completed.fileId),
|
|
||||||
preparedTransport = msgCipher
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun emitProgress(jobId: String, value: Int) {
|
|
||||||
AttachmentUploadEvents.flow.tryEmit(
|
|
||||||
AttachmentUploadProgress.InProgress(
|
|
||||||
jobId = jobId,
|
|
||||||
percent = value.coerceIn(0, 100)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun clearResumableState(jobId: String) {
|
|
||||||
settings.putString(uploadIdKey(jobId), "")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun uploadIdKey(jobId: String): String = "dm_upload_id_$jobId"
|
|
||||||
|
|
||||||
private suspend fun savePreparedTransportAndBlob(
|
|
||||||
context: Context,
|
|
||||||
jobId: String,
|
|
||||||
blob: ByteArray,
|
|
||||||
cipher: TransportCiphertext
|
|
||||||
) {
|
|
||||||
val f = dmBlobCacheFile(context, jobId)
|
|
||||||
f.outputStream().use { it.write(blob) }
|
|
||||||
val json = JSONObject().apply {
|
|
||||||
put("clientPublicKeyB64", cipher.clientPublicKeyB64)
|
|
||||||
put("nonceB64", cipher.nonceB64)
|
|
||||||
put("ciphertextB64", cipher.ciphertextB64)
|
|
||||||
}
|
|
||||||
settings.putString(dmTransportCipherPrefsKey(jobId), json.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadPreparedTransportAndBlob(
|
|
||||||
context: Context,
|
|
||||||
jobId: String
|
|
||||||
): Pair<ByteArray, TransportCiphertext>? {
|
|
||||||
val f = dmBlobCacheFile(context, jobId)
|
|
||||||
val raw = settings.getString(dmTransportCipherPrefsKey(jobId), "").ifBlank { return null }
|
|
||||||
if (!f.isFile || f.length() == 0L) return null
|
|
||||||
return try {
|
|
||||||
val o = JSONObject(raw)
|
|
||||||
val cipher = TransportCiphertext(
|
|
||||||
clientPublicKeyB64 = o.getString("clientPublicKeyB64"),
|
|
||||||
nonceB64 = o.getString("nonceB64"),
|
|
||||||
ciphertextB64 = o.getString("ciphertextB64")
|
|
||||||
)
|
|
||||||
f.readBytes() to cipher
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun clearPreparedTransportAndBlob(context: Context, jobId: String) {
|
|
||||||
dmBlobCacheFile(context, jobId).delete()
|
|
||||||
settings.putString(dmTransportCipherPrefsKey(jobId), "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -3,13 +3,41 @@ package ru.fromchat.api.db
|
|||||||
import app.cash.sqldelight.db.SqlDriver
|
import app.cash.sqldelight.db.SqlDriver
|
||||||
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
||||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
import java.io.File
|
||||||
import ru.fromchat.db.MessageDatabase
|
import ru.fromchat.db.MessageDatabase
|
||||||
|
|
||||||
actual fun provideMessageDatabaseDriver(): SqlDriver {
|
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(
|
return AndroidSqliteDriver(
|
||||||
schema = MessageDatabase.Schema,
|
schema = MessageDatabase.Schema,
|
||||||
context = UtilsLibrary.context,
|
context = context,
|
||||||
name = "message_database.db"
|
name = dbFile.absolutePath,
|
||||||
|
callback = DiffOnlyDatabaseCallback(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drops the pre-cache-dir DB; message cache lives under cache only. */
|
||||||
|
private fun removeLegacyDatabaseFiles(legacyDb: File) {
|
||||||
|
if (!legacyDb.exists()) return
|
||||||
|
runCatching { legacyDb.delete() }
|
||||||
|
runCatching { File("${legacyDb.path}-journal").delete() }
|
||||||
|
runCatching { File("${legacyDb.path}-wal").delete() }
|
||||||
|
runCatching { File("${legacyDb.path}-shm").delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLDelight [user_version] is not used for migrations; [ensureMessageDatabaseSchema] diffs structure.
|
||||||
|
*/
|
||||||
|
private class DiffOnlyDatabaseCallback : AndroidSqliteDriver.Callback(MessageDatabase.Schema) {
|
||||||
|
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||||
|
// Schema is created by structural diff on first open.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||||
|
// No version-based migration.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package ru.fromchat.api.db
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
internal actual fun <T> withMessageDatabaseLock(block: () -> T): T = synchronized(lock, block)
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package ru.fromchat.api.outbox
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.work.ForegroundInfo
|
||||||
|
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.notif_media_upload_channel_name
|
||||||
|
import ru.fromchat.notif_media_upload_progress
|
||||||
|
import ru.fromchat.notif_media_upload_text
|
||||||
|
import ru.fromchat.notif_media_upload_title
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "fromchat_media_upload"
|
||||||
|
private const val NOTIFICATION_ID = 0xFC10
|
||||||
|
|
||||||
|
object MediaUploadForegroundHelper {
|
||||||
|
suspend fun foregroundInfo(
|
||||||
|
context: Context = UtilsLibrary.context,
|
||||||
|
percent: Int? = null,
|
||||||
|
filename: String? = null,
|
||||||
|
): ForegroundInfo {
|
||||||
|
val channelName = getString(Res.string.notif_media_upload_channel_name)
|
||||||
|
val title = getString(Res.string.notif_media_upload_title)
|
||||||
|
val defaultText = getString(Res.string.notif_media_upload_text)
|
||||||
|
ensureChannel(context, channelName)
|
||||||
|
val contentText = when {
|
||||||
|
percent != null && !filename.isNullOrBlank() ->
|
||||||
|
getString(Res.string.notif_media_upload_progress, percent.coerceIn(0, 100), filename)
|
||||||
|
percent != null -> "$percent%"
|
||||||
|
else -> defaultText
|
||||||
|
}
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(contentText)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(Notification.CATEGORY_PROGRESS)
|
||||||
|
when (val p = percent?.coerceIn(0, 100)) {
|
||||||
|
null -> builder.setProgress(100, 0, true)
|
||||||
|
else -> builder.setProgress(100, p, false)
|
||||||
|
}
|
||||||
|
val notification = builder.build()
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
ForegroundInfo(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ForegroundInfo(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannel(context: Context, channelName: String) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
channelName,
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package ru.fromchat.api.outbox
|
||||||
|
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.OutOfQuotaPolicy
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||||
|
|
||||||
|
actual fun scheduleOutboxProcessing(instanceId: String) {
|
||||||
|
val id = instanceId.trim()
|
||||||
|
if (id.isEmpty()) return
|
||||||
|
val request = OneTimeWorkRequestBuilder<OutboxSendWorker>()
|
||||||
|
.setInputData(
|
||||||
|
Data.Builder()
|
||||||
|
.putString(OutboxSendWorker.KEY_INSTANCE_ID, id)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(UtilsLibrary.context).enqueueUniqueWork(
|
||||||
|
"outbox-send-$id",
|
||||||
|
ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun cancelOutboxProcessing(instanceId: String) {
|
||||||
|
val id = instanceId.trim()
|
||||||
|
if (id.isEmpty()) return
|
||||||
|
WorkManager.getInstance(UtilsLibrary.context).cancelUniqueWork("outbox-send-$id")
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package ru.fromchat.api.outbox
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.fromchat.api.AttachmentUploadNotifier
|
||||||
|
import ru.fromchat.api.AttachmentUploadProgress
|
||||||
|
import ru.fromchat.core.cache.CacheContext
|
||||||
|
|
||||||
|
class OutboxSendWorker(
|
||||||
|
context: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result = coroutineScope {
|
||||||
|
val instanceId = inputData.getString(KEY_INSTANCE_ID)?.trim().orEmpty()
|
||||||
|
.ifEmpty { CacheContext.activeInstanceId.value.trim() }
|
||||||
|
if (instanceId.isEmpty()) return@coroutineScope Result.success()
|
||||||
|
val hasMedia = DmAttachmentOutboxHandler.hasPendingAttachmentWork(instanceId)
|
||||||
|
val progressJob = if (hasMedia) {
|
||||||
|
launch {
|
||||||
|
AttachmentUploadNotifier.progressFlow.collect { progress ->
|
||||||
|
when (progress) {
|
||||||
|
is AttachmentUploadProgress.InProgress -> {
|
||||||
|
setForeground(
|
||||||
|
MediaUploadForegroundHelper.foregroundInfo(
|
||||||
|
percent = progress.percent,
|
||||||
|
filename = progress.filename,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is AttachmentUploadProgress.Pending -> {
|
||||||
|
setForeground(
|
||||||
|
MediaUploadForegroundHelper.foregroundInfo(
|
||||||
|
percent = 0,
|
||||||
|
filename = progress.filename,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (hasMedia) {
|
||||||
|
setForeground(MediaUploadForegroundHelper.foregroundInfo(percent = 0))
|
||||||
|
}
|
||||||
|
val allOk = try {
|
||||||
|
OutgoingMessageCoordinator.drainOutboxForInstance(instanceId)
|
||||||
|
} finally {
|
||||||
|
progressJob?.cancel()
|
||||||
|
}
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY_INSTANCE_ID = "instance_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
actual suspend fun wipeFromChatCacheDirectory() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
File(UtilsLibrary.context.cacheDir, "fromchat").deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.fromchat.api.db.MessageDatabaseProvider
|
||||||
|
import ru.fromchat.core.cache.wipeFromChatCacheDirectory
|
||||||
|
|
||||||
|
private const val GENERATION_FILE = ".generation"
|
||||||
|
|
||||||
|
actual suspend fun ensureFromChatCacheGeneration() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val root = File(UtilsLibrary.context.cacheDir, "fromchat")
|
||||||
|
val marker = File(root, GENERATION_FILE)
|
||||||
|
if (marker.isFile) return@withContext
|
||||||
|
MessageDatabaseProvider.closeAndReset()
|
||||||
|
wipeFromChatCacheDirectory()
|
||||||
|
marker.parentFile?.mkdirs()
|
||||||
|
marker.writeText("1\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun writeFromChatCacheGeneration() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val root = File(UtilsLibrary.context.cacheDir, "fromchat")
|
||||||
|
root.mkdirs()
|
||||||
|
File(root, GENERATION_FILE).writeText("1\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private fun uploadDir(instanceId: String): File {
|
||||||
|
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
|
return File(UtilsLibrary.context.cacheDir, "fromchat/instances/$safe/uploads").apply { mkdirs() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun blobFile(instanceId: String, clientMessageId: String): File {
|
||||||
|
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
|
return File(uploadDir(instanceId), "$safeId.enc")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cipherFile(instanceId: String, clientMessageId: String): File {
|
||||||
|
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
|
return File(uploadDir(instanceId), "$safeId.cipher.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sourceFile(instanceId: String, clientMessageId: String): File {
|
||||||
|
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
|
return File(uploadDir(instanceId), "$safeId.source")
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun stageOutboundFileForUpload(
|
||||||
|
instanceId: String,
|
||||||
|
clientMessageId: String,
|
||||||
|
sourceUri: String,
|
||||||
|
): StagedOutboundFile = withContext(Dispatchers.IO) {
|
||||||
|
val dest = sourceFile(instanceId, clientMessageId)
|
||||||
|
val destUri = Uri.fromFile(dest).toString()
|
||||||
|
if (sourceUri != destUri && sourceUri != dest.absolutePath) {
|
||||||
|
if (!dest.isFile || dest.length() == 0L) {
|
||||||
|
val input = when {
|
||||||
|
sourceUri.startsWith("content://") || sourceUri.startsWith("file://") ->
|
||||||
|
UtilsLibrary.context.contentResolver.openInputStream(Uri.parse(sourceUri))
|
||||||
|
else -> File(sourceUri).inputStream()
|
||||||
|
} ?: throw OutboundFileUnavailableException("Failed to read file from URI")
|
||||||
|
input.use { inputStream ->
|
||||||
|
dest.outputStream().use { output -> inputStream.copyTo(output) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StagedOutboundFile(uri = destUri, sizeBytes = dest.length().coerceAtLeast(0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
when {
|
||||||
|
fileUri.startsWith("content://") ->
|
||||||
|
UtilsLibrary.context.contentResolver.openInputStream(Uri.parse(fileUri))?.use { it.readBytes() }
|
||||||
|
?: throw OutboundFileUnavailableException("Failed to read file from URI")
|
||||||
|
fileUri.startsWith("file://") -> {
|
||||||
|
val path = Uri.parse(fileUri).path ?: throw OutboundFileUnavailableException("Invalid file URI")
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.isFile) throw OutboundFileUnavailableException("File no longer exists")
|
||||||
|
file.readBytes()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val file = File(fileUri)
|
||||||
|
if (!file.isFile) throw OutboundFileUnavailableException("File no longer exists")
|
||||||
|
file.readBytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
blobFile(instanceId, clientMessageId).outputStream().use { it.write(bytes) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val f = blobFile(instanceId, clientMessageId)
|
||||||
|
if (!f.isFile || f.length() == 0L) null else f.readBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
cipherFile(instanceId, clientMessageId).writeText(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun loadUploadTransportCipherJson(instanceId: String, clientMessageId: String): String? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val f = cipherFile(instanceId, clientMessageId)
|
||||||
|
if (!f.isFile) null else f.readText().takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
blobFile(instanceId, clientMessageId).delete()
|
||||||
|
cipherFile(instanceId, clientMessageId).delete()
|
||||||
|
sourceFile(instanceId, clientMessageId).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun clearUploadSecretsOnly(instanceId: String, clientMessageId: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
blobFile(instanceId, clientMessageId).delete()
|
||||||
|
cipherFile(instanceId, clientMessageId).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,17 +34,27 @@ actual object DmCrypto {
|
|||||||
actual suspend fun decryptEnvelope(
|
actual suspend fun decryptEnvelope(
|
||||||
ivB64: String,
|
ivB64: String,
|
||||||
ciphertextB64: String,
|
ciphertextB64: String,
|
||||||
mek: ByteArray
|
mek: ByteArray,
|
||||||
): ByteArray = withContext(Dispatchers.Default) {
|
): ByteArray = withContext(Dispatchers.Default) {
|
||||||
require(mek.size == AES_KEY_SIZE) { "MEK must be 32 bytes" }
|
|
||||||
|
|
||||||
val iv = Base64.decode(ivB64)
|
val iv = Base64.decode(ivB64)
|
||||||
val ciphertext = Base64.decode(ciphertextB64)
|
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(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes" }
|
||||||
require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" }
|
require(ciphertext.size >= GCM_TAG_SIZE) { "Ciphertext too short" }
|
||||||
|
return try {
|
||||||
try {
|
|
||||||
BackupCryptoPlatform.aesGcmDecrypt(mek, iv, ciphertext)
|
BackupCryptoPlatform.aesGcmDecrypt(mek, iv, ciphertext)
|
||||||
} catch (e: GeneralSecurityException) {
|
} catch (e: GeneralSecurityException) {
|
||||||
throw DmCiphertextCorruptedException(cause = e)
|
throw DmCiphertextCorruptedException(cause = e)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ru.fromchat.ui.chat
|
|||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -22,12 +23,100 @@ actual fun getFilenameFromUri(uri: String): String {
|
|||||||
|
|
||||||
actual suspend fun getImageAspectRatio(uri: String): Float? {
|
actual suspend fun getImageAspectRatio(uri: String): Float? {
|
||||||
val context = com.pr0gramm3r101.utils.UtilsLibrary.context
|
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 }
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
BitmapFactory.decodeStream(stream, null, options)
|
BitmapFactory.decodeStream(stream, null, options)
|
||||||
val w = options.outWidth
|
var w = options.outWidth
|
||||||
val h = options.outHeight
|
var h = options.outHeight
|
||||||
if (w > 0 && h > 0) return w.toFloat() / h
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.util.LruCache
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
|
||||||
|
actual object PlatformDecodedBitmapCache {
|
||||||
|
private val cache: LruCache<String, ImageBitmap> = object : LruCache<String, ImageBitmap>(maxCacheBytes()) {
|
||||||
|
override fun sizeOf(key: String, value: ImageBitmap): Int =
|
||||||
|
value.width * value.height * 4
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun get(key: String): ImageBitmap? = cache.get(key)
|
||||||
|
|
||||||
|
actual fun put(key: String, bitmap: ImageBitmap) {
|
||||||
|
cache.put(key, bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun remove(key: String) {
|
||||||
|
cache.remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun evictPrefix(prefix: String) {
|
||||||
|
val snapshot = cache.snapshot()
|
||||||
|
for (entryKey in snapshot.keys) {
|
||||||
|
if (entryKey.startsWith(prefix)) {
|
||||||
|
cache.remove(entryKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxCacheBytes(): Int =
|
||||||
|
(Runtime.getRuntime().maxMemory() / 8).toInt().coerceAtLeast(4 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun decodeLocalImageFile(absolutePath: String, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? =
|
||||||
|
decodeSampledFromFile(absolutePath, reqWidthPx, reqHeightPx)?.asImageBitmap()
|
||||||
|
|
||||||
|
actual fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? =
|
||||||
|
decodeSampledFromBytes(bytes, reqWidthPx, reqHeightPx)?.asImageBitmap()
|
||||||
|
|
||||||
|
private fun decodeSampledFromFile(path: String, reqWidthPx: Int, reqHeightPx: Int): Bitmap? {
|
||||||
|
val orientation = readExifOrientation(path)
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(path, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
val (orientedW, orientedH) = orientedDimensions(bounds.outWidth, bounds.outHeight, orientation)
|
||||||
|
val sampleSize = calculateInSampleSize(orientedW, orientedH, reqWidthPx, reqHeightPx)
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = sampleSize
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
}
|
||||||
|
val decoded = BitmapFactory.decodeFile(path, options) ?: return null
|
||||||
|
val oriented = applyExifOrientation(decoded, orientation)
|
||||||
|
return scaleBitmapToFitWithin(oriented, reqWidthPx, reqHeightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeSampledFromBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): Bitmap? {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
val sampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, reqWidthPx, reqHeightPx)
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = sampleSize
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
}
|
||||||
|
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null
|
||||||
|
return scaleBitmapToFitWithin(decoded, reqWidthPx, reqHeightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downscale only — never upscale or crop; [ContentScale.Crop] on the tile handles fill. */
|
||||||
|
private fun scaleBitmapToFitWithin(bitmap: Bitmap, reqWidthPx: Int, reqHeightPx: Int): Bitmap {
|
||||||
|
if (bitmap.width <= reqWidthPx && bitmap.height <= reqHeightPx) return bitmap
|
||||||
|
val scale = minOf(
|
||||||
|
reqWidthPx.toFloat() / bitmap.width.toFloat(),
|
||||||
|
reqHeightPx.toFloat() / bitmap.height.toFloat(),
|
||||||
|
)
|
||||||
|
if (scale >= 1f) return bitmap
|
||||||
|
val dstW = (bitmap.width * scale).toInt().coerceAtLeast(1)
|
||||||
|
val dstH = (bitmap.height * scale).toInt().coerceAtLeast(1)
|
||||||
|
return Bitmap.createScaledBitmap(bitmap, dstW, dstH, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readExifOrientation(path: String): Int =
|
||||||
|
runCatching {
|
||||||
|
ExifInterface(path).getAttributeInt(
|
||||||
|
ExifInterface.TAG_ORIENTATION,
|
||||||
|
ExifInterface.ORIENTATION_NORMAL,
|
||||||
|
)
|
||||||
|
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
||||||
|
|
||||||
|
private fun orientedDimensions(width: Int, height: Int, orientation: Int): Pair<Int, Int> =
|
||||||
|
when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90,
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270,
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE,
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE,
|
||||||
|
-> height to width
|
||||||
|
else -> width to height
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
|
||||||
|
val matrix = Matrix()
|
||||||
|
when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||||
|
matrix.postRotate(90f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||||
|
matrix.postRotate(270f)
|
||||||
|
matrix.postScale(-1f, 1f)
|
||||||
|
}
|
||||||
|
else -> return bitmap
|
||||||
|
}
|
||||||
|
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
|
?: bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Android docs: largest inSampleSize where both dimensions stay >= requested. */
|
||||||
|
private fun calculateInSampleSize(
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
reqWidthPx: Int,
|
||||||
|
reqHeightPx: Int,
|
||||||
|
): Int {
|
||||||
|
var inSampleSize = 1
|
||||||
|
if (height > reqHeightPx || width > reqWidthPx) {
|
||||||
|
var halfHeight = height / 2
|
||||||
|
var halfWidth = width / 2
|
||||||
|
while (halfHeight / inSampleSize >= reqHeightPx && halfWidth / inSampleSize >= reqWidthPx) {
|
||||||
|
inSampleSize *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inSampleSize.coerceAtLeast(1)
|
||||||
|
}
|
||||||
@@ -64,16 +64,25 @@
|
|||||||
<string name="notif_call_channel_name">Активный звонок</string>
|
<string name="notif_call_channel_name">Активный звонок</string>
|
||||||
<string name="notif_call_ongoing_title">Видеозвонок</string>
|
<string name="notif_call_ongoing_title">Видеозвонок</string>
|
||||||
<string name="notif_call_ongoing_text">Камера и микрофон остаются включёнными, пока вы вне приложения</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="message_sender_you">Вы</string>
|
||||||
<string name="user_fallback">Человек %1$d</string>
|
<string name="user_fallback">Человек %1$d</string>
|
||||||
<string name="message_corrupted">Это сообщение не удалось показать.</string>
|
<string name="message_corrupted">Это сообщение не удалось показать.</string>
|
||||||
<string name="message_edited_suffix">(изменено)</string>
|
<string name="message_edited_suffix">(изменено)</string>
|
||||||
<string name="message_replying_to">Ответ %1$s</string>
|
<string name="message_replying_to">Ответ %1$s</string>
|
||||||
<string name="message_corrupted_short">Сообщение не показывается</string>
|
<string name="message_corrupted_short">Сообщение не показывается</string>
|
||||||
|
<string name="attachment_image_load_failed">Не удалось загрузить</string>
|
||||||
|
<string name="attachment_retry">Повторить</string>
|
||||||
|
<string name="cd_attachment_retry">Повторить загрузку изображения</string>
|
||||||
<string name="message_editing_title">Правка сообщения</string>
|
<string name="message_editing_title">Правка сообщения</string>
|
||||||
<string name="action_reply">Ответить</string>
|
<string name="action_reply">Ответить</string>
|
||||||
<string name="action_edit">Изменить</string>
|
<string name="action_edit">Изменить</string>
|
||||||
<string name="action_delete">Удалить</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="action_save">Сохранить</string>
|
||||||
<string name="cd_close">Закрыть</string>
|
<string name="cd_close">Закрыть</string>
|
||||||
<string name="cd_remove">Убрать</string>
|
<string name="cd_remove">Убрать</string>
|
||||||
@@ -152,6 +161,16 @@
|
|||||||
<string name="server_config_action_reset">Сбросить</string>
|
<string name="server_config_action_reset">Сбросить</string>
|
||||||
<string name="server_config_action_reset_confirm_title">Сбросить настройки?</string>
|
<string name="server_config_action_reset_confirm_title">Сбросить настройки?</string>
|
||||||
<string name="server_config_action_reset_confirm_body">Будут восстановлены адрес сервера и порты по умолчанию.</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="save_continue">Сохранить и продолжить</string>
|
||||||
<string name="change_server">Сменить сервер</string>
|
<string name="change_server">Сменить сервер</string>
|
||||||
<string name="change_server_d">Подключиться к альтернативному серверу FromChat и выйти из аккаунта.</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_channel_name">Ongoing call</string>
|
||||||
<string name="notif_call_ongoing_title">Video call</string>
|
<string name="notif_call_ongoing_title">Video call</string>
|
||||||
<string name="notif_call_ongoing_text">Camera and microphone stay active while you’re away</string>
|
<string name="notif_call_ongoing_text">Camera and microphone stay active while you’re away</string>
|
||||||
|
<string name="notif_media_upload_channel_name">Media upload</string>
|
||||||
|
<string name="notif_media_upload_title">Sending attachment</string>
|
||||||
|
<string name="notif_media_upload_text">Upload continues in the background</string>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<string name="message_sender_you">You</string>
|
<string name="message_sender_you">You</string>
|
||||||
@@ -83,13 +86,19 @@
|
|||||||
<string name="message_edited_suffix">(edited)</string>
|
<string name="message_edited_suffix">(edited)</string>
|
||||||
<string name="message_replying_to">Reply to %1$s</string>
|
<string name="message_replying_to">Reply to %1$s</string>
|
||||||
<string name="message_corrupted_short">Can’t show this message</string>
|
<string name="message_corrupted_short">Can’t show this message</string>
|
||||||
|
<string name="attachment_image_load_failed">Failed to load</string>
|
||||||
|
<string name="attachment_retry">Retry</string>
|
||||||
|
<string name="cd_attachment_retry">Retry loading image</string>
|
||||||
<string name="message_editing_title">Edit message</string>
|
<string name="message_editing_title">Edit message</string>
|
||||||
|
|
||||||
<!-- Message actions -->
|
<!-- Message actions -->
|
||||||
<string name="action_reply">Reply</string>
|
<string name="action_reply">Reply</string>
|
||||||
<string name="action_edit">Edit</string>
|
<string name="action_edit">Edit</string>
|
||||||
<string name="action_delete">Delete</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="action_save">Save</string>
|
||||||
|
<string name="notif_media_upload_progress">%1$d%% · %2$s</string>
|
||||||
|
|
||||||
<!-- Chat input -->
|
<!-- Chat input -->
|
||||||
<string name="cd_close">Close</string>
|
<string name="cd_close">Close</string>
|
||||||
@@ -179,6 +188,17 @@
|
|||||||
<string name="server_config_action_reset">Reset</string>
|
<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_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_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>
|
<string name="save_continue">Save and continue</string>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ import io.ktor.client.plugins.websocket.pingInterval
|
|||||||
import io.ktor.client.request.bearerAuth
|
import io.ktor.client.request.bearerAuth
|
||||||
import io.ktor.client.request.delete
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.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.patch
|
||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
@@ -36,6 +44,8 @@ import kotlinx.serialization.json.Json
|
|||||||
import kotlinx.serialization.json.encodeToJsonElement
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
import ru.fromchat.core.Settings
|
import ru.fromchat.core.Settings
|
||||||
import ru.fromchat.core.config.Config
|
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.chat.PublicChatPanelCache
|
||||||
import ru.fromchat.ui.dm.DmPanelCache
|
import ru.fromchat.ui.dm.DmPanelCache
|
||||||
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
|
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
|
||||||
@@ -150,6 +160,12 @@ object ApiClient {
|
|||||||
// - 403 => forbidden, keep session intact (used for read-only/suspension workflows).
|
// - 403 => forbidden, keep session intact (used for read-only/suspension workflows).
|
||||||
HttpResponseValidator {
|
HttpResponseValidator {
|
||||||
validateResponse { response ->
|
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) {
|
if (response.status.value == 401) {
|
||||||
token = null
|
token = null
|
||||||
user = null
|
user = null
|
||||||
@@ -194,6 +210,20 @@ object ApiClient {
|
|||||||
defaultRequest {
|
defaultRequest {
|
||||||
header("User-Agent", userAgent)
|
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)
|
val id = fetchServerInstanceId(Config.apiBaseUrl)
|
||||||
if (id.isNotEmpty()) {
|
if (id.isNotEmpty()) {
|
||||||
Settings.lastKnownServerInstanceId = id
|
Settings.lastKnownServerInstanceId = id
|
||||||
|
InstanceRegistryStore.registerInstanceEncountered(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -546,8 +577,7 @@ object ApiClient {
|
|||||||
* Fetch encrypted file bytes. Path is e.g. "/uploads/files/encrypted/xxx.jpg" or "/api/uploads/files/encrypted/xxx.jpg".
|
* 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.
|
* Backend may return path with /api prefix; apiBaseUrl already includes /api, so we avoid double /api.
|
||||||
*/
|
*/
|
||||||
suspend fun fetchEncryptedFile(path: String): ByteArray {
|
fun encryptedFileUrl(path: String): String = when {
|
||||||
val url = when {
|
|
||||||
path.startsWith("http") -> path
|
path.startsWith("http") -> path
|
||||||
path.startsWith("/api") -> {
|
path.startsWith("/api") -> {
|
||||||
val serverBase = Config.apiBaseUrl.removeSuffix("/api")
|
val serverBase = Config.apiBaseUrl.removeSuffix("/api")
|
||||||
@@ -555,7 +585,139 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
else -> "${Config.apiBaseUrl}$path"
|
else -> "${Config.apiBaseUrl}$path"
|
||||||
}
|
}
|
||||||
return http.get(url).body()
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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() {
|
suspend fun sendTyping() {
|
||||||
if (_suspensionState.value.isSuspended) return
|
if (_suspensionState.value.isSuspended) return
|
||||||
runCatching {
|
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 {
|
sealed class AttachmentUploadProgress {
|
||||||
data class Pending(val jobId: String, val filename: String) : 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 Success(val jobId: String) : AttachmentUploadProgress()
|
||||||
data class Failed(val jobId: String, val error: 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
|
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
|
@Serializable
|
||||||
data class DmTypingData(
|
data class DmTypingData(
|
||||||
@SerialName("recipientId") val recipientId: Int
|
@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
|
package ru.fromchat.api
|
||||||
|
|
||||||
import com.pr0gramm3r101.utils.settings.settings
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
import ru.fromchat.api.db.ProfileCacheStore
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,21 +27,16 @@ fun UserProfile.visibleDisplayName(currentUserId: Int? = null): String? =
|
|||||||
displayName?.trim()?.ifEmpty { null } ?: visibleUsername(currentUserId)
|
displayName?.trim()?.ifEmpty { null } ?: visibleUsername(currentUserId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory profile cache with disk persistence. [get] reads a volatile snapshot (lock-free).
|
* In-memory profile cache for the active [ru.fromchat.core.cache.CacheContext] instance,
|
||||||
* [put] copy-on-writes the map and schedules an async flush of the full list to settings.
|
* backed by SQLDelight [profile_cache] per instance partition.
|
||||||
*/
|
*/
|
||||||
object ProfileCache {
|
object ProfileCache {
|
||||||
private const val SETTINGS_KEY = "profile_cache_profiles_v1"
|
|
||||||
|
|
||||||
private val json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
isLenient = true
|
|
||||||
encodeDefaults = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var profiles: Map<Int, UserProfile> = emptyMap()
|
private var profiles: Map<Int, UserProfile> = emptyMap()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var loadedInstanceId: String = ""
|
||||||
|
|
||||||
private val persistMutex = Mutex()
|
private val persistMutex = Mutex()
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
@@ -60,24 +53,26 @@ object ProfileCache {
|
|||||||
}
|
}
|
||||||
val cur = profiles
|
val cur = profiles
|
||||||
profiles = cur + (profile.id to profile)
|
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) {
|
fun remove(userId: Int) {
|
||||||
val cur = profiles
|
val cur = profiles
|
||||||
if (userId !in cur) return
|
if (userId !in cur) return
|
||||||
profiles = cur - userId
|
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) {
|
fun evictUnusableClientPreview(userId: Int) {
|
||||||
val p = get(userId) ?: return
|
val p = get(userId) ?: return
|
||||||
if (!p.isClientPreviewOnly) return
|
if (!p.isClientPreviewOnly) return
|
||||||
@@ -86,11 +81,6 @@ object ProfileCache {
|
|||||||
if (!hasIdentity) remove(userId)
|
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) {
|
fun mergeFromDmUser(user: User) {
|
||||||
val existing = get(user.id)
|
val existing = get(user.id)
|
||||||
if (existing != null && !existing.isClientPreviewOnly) return
|
if (existing != null && !existing.isClientPreviewOnly) return
|
||||||
@@ -116,8 +106,8 @@ object ProfileCache {
|
|||||||
suspended = existing?.suspended,
|
suspended = existing?.suspended,
|
||||||
suspensionReason = existing?.suspensionReason,
|
suspensionReason = existing?.suspensionReason,
|
||||||
deleted = existing?.deleted,
|
deleted = existing?.deleted,
|
||||||
isClientPreviewOnly = true
|
isClientPreviewOnly = true,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,42 +135,38 @@ object ProfileCache {
|
|||||||
suspended = existing?.suspended,
|
suspended = existing?.suspended,
|
||||||
suspensionReason = existing?.suspensionReason,
|
suspensionReason = existing?.suspensionReason,
|
||||||
deleted = existing?.deleted,
|
deleted = existing?.deleted,
|
||||||
isClientPreviewOnly = true
|
isClientPreviewOnly = true,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun onActiveInstanceChanged(instanceId: String) {
|
||||||
* Load stored profiles into memory (merged: existing runtime entries win on id clash).
|
ioScope.launch {
|
||||||
* Call after [ApiClient.loadPersistedData].
|
|
||||||
*/
|
|
||||||
suspend fun hydrateFromDisk() {
|
|
||||||
persistMutex.withLock {
|
persistMutex.withLock {
|
||||||
val raw = settings.getString(SETTINGS_KEY, "").ifBlank { return }
|
loadedInstanceId = instanceId
|
||||||
runCatching {
|
profiles = if (instanceId.isNotEmpty()) {
|
||||||
val list = json.decodeFromString(ListSerializer(UserProfile.serializer()), raw)
|
runCatching { ProfileCacheStore.loadAllForInstance(instanceId) }.getOrDefault(emptyMap())
|
||||||
val usable = list.filter { p ->
|
} else {
|
||||||
when {
|
emptyMap()
|
||||||
!p.isClientPreviewOnly -> true
|
}
|
||||||
else ->
|
pruneUnusableClientPreviewsLocked()
|
||||||
p.username.trim().isNotEmpty() ||
|
|
||||||
!p.displayName.isNullOrBlank()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val fromDisk = usable.associateBy { it.id }
|
|
||||||
profiles = fromDisk + profiles
|
|
||||||
if (usable.size < list.size) {
|
|
||||||
schedulePersist()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun hydrateFromDisk() {
|
||||||
|
val instanceId = ru.fromchat.core.cache.CacheContext.activeInstanceId.value.trim()
|
||||||
|
persistMutex.withLock {
|
||||||
|
loadedInstanceId = instanceId
|
||||||
|
profiles = if (instanceId.isNotEmpty()) {
|
||||||
|
runCatching { ProfileCacheStore.loadAllForInstance(instanceId) }.getOrDefault(emptyMap())
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
}
|
}
|
||||||
pruneUnusableClientPreviewsLocked()
|
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() {
|
private fun pruneUnusableClientPreviewsLocked() {
|
||||||
val snap = profiles
|
val snap = profiles
|
||||||
val toRemove = snap.filter { (_, p) ->
|
val toRemove = snap.filter { (_, p) ->
|
||||||
@@ -194,26 +180,12 @@ object ProfileCache {
|
|||||||
cur = cur - id
|
cur = cur - id
|
||||||
}
|
}
|
||||||
profiles = cur
|
profiles = cur
|
||||||
schedulePersist()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun clear() {
|
suspend fun clear() {
|
||||||
persistMutex.withLock {
|
persistMutex.withLock {
|
||||||
profiles = emptyMap()
|
profiles = emptyMap()
|
||||||
settings.putString(SETTINGS_KEY, "")
|
loadedInstanceId = ""
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ru.fromchat.api
|
|||||||
|
|
||||||
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
|
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
|
||||||
import io.ktor.client.plugins.websocket.webSocket
|
import io.ktor.client.plugins.websocket.webSocket
|
||||||
|
import io.ktor.client.request.header
|
||||||
import io.ktor.client.request.url
|
import io.ktor.client.request.url
|
||||||
import io.ktor.http.HttpMethod
|
import io.ktor.http.HttpMethod
|
||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
@@ -26,7 +27,9 @@ import kotlinx.serialization.json.jsonObject
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import ru.fromchat.AppForeground
|
import ru.fromchat.AppForeground
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
|
import ru.fromchat.core.cache.CacheContext
|
||||||
import ru.fromchat.core.config.Config
|
import ru.fromchat.core.config.Config
|
||||||
|
import ru.fromchat.core.instance.InstanceIdGuard
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
@@ -146,10 +149,14 @@ object WebSocketManager {
|
|||||||
connecting = true
|
connecting = true
|
||||||
ConnectionStateStore.onConnecting()
|
ConnectionStateStore.onConnecting()
|
||||||
|
|
||||||
|
val instanceId = CacheContext.activeInstanceId.value.trim()
|
||||||
ApiClient.http.webSocket(
|
ApiClient.http.webSocket(
|
||||||
method = HttpMethod.Get,
|
method = HttpMethod.Get,
|
||||||
request = {
|
request = {
|
||||||
url(wsUrl)
|
url(wsUrl)
|
||||||
|
if (instanceId.isNotEmpty()) {
|
||||||
|
header(InstanceIdGuard.INSTANCE_ID_HEADER, instanceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
reconnectDelayMs = MIN_RECONNECT_DELAY_MS
|
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
|
package ru.fromchat.api.db
|
||||||
|
|
||||||
|
import app.cash.sqldelight.coroutines.asFlow
|
||||||
|
import app.cash.sqldelight.coroutines.mapToList
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.DmConversation
|
import ru.fromchat.api.DmConversation
|
||||||
import ru.fromchat.api.Message
|
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.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.Conversation
|
||||||
import ru.fromchat.db.MessageDatabase
|
import ru.fromchat.db.MessageDatabase
|
||||||
import ru.fromchat.db.Message as DbMessage
|
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(
|
data class CachedConversation(
|
||||||
val id: String,
|
val id: String,
|
||||||
val otherUserId: Int,
|
val otherUserId: Int,
|
||||||
@@ -26,16 +33,11 @@ data class CachedConversation(
|
|||||||
|
|
||||||
object MessageCacheStore {
|
object MessageCacheStore {
|
||||||
private val db: MessageDatabase get() = MessageDatabaseProvider.database
|
private val db: MessageDatabase get() = MessageDatabaseProvider.database
|
||||||
|
private val outboxJson = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
|
||||||
private fun conversationIdForPublic(): String = "public"
|
private fun instanceId(): String = CacheContext.requireActiveInstanceId()
|
||||||
private fun conversationIdForDm(otherUserId: Int): String = "dm:$otherUserId"
|
|
||||||
|
|
||||||
private fun otherUserIdFromDmConversationId(conversationId: String): Int? =
|
private fun conversationIdForPublic(): String = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
|
||||||
if (conversationId.startsWith("dm:")) {
|
|
||||||
conversationId.removePrefix("dm:").toIntOrNull()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun truncateDmListPreview(text: String, maxLen: Int = 120): String {
|
private fun truncateDmListPreview(text: String, maxLen: Int = 120): String {
|
||||||
val t = text.trim()
|
val t = text.trim()
|
||||||
@@ -43,73 +45,48 @@ object MessageCacheStore {
|
|||||||
return if (t.length > maxLen) t.take(maxLen) + "\u2026" else t
|
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). */
|
fun observeMessages(instanceId: String, conversationId: String): Flow<List<Message>> =
|
||||||
private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) {
|
db.messageDatabaseQueries
|
||||||
val convId = conversationIdForDm(otherUserId)
|
.selectMessagesByConversation(instanceId, conversationId)
|
||||||
withContext(Dispatchers.Default) {
|
.asFlow()
|
||||||
val row = db.messageDatabaseQueries.selectConversations().executeAsList()
|
.mapToList(Dispatchers.Default)
|
||||||
.find { it.id == convId } ?: return@withContext
|
.map { rows ->
|
||||||
val recent = db.messageDatabaseQueries
|
val raw = rows.map { it.toAppMessage() }
|
||||||
.selectRecentMessagesByConversation(convId, 1)
|
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
||||||
.executeAsList()
|
sortMessagesForChatDisplay(
|
||||||
.firstOrNull()
|
validatedOrEmpty(
|
||||||
val rawPreview = recent?.content?.orEmpty()?.trim().orEmpty()
|
conversationId,
|
||||||
val preview = rawPreview.takeIf { it.isNotEmpty() }
|
dedupeMessagesByClientId(
|
||||||
?.let { truncateDmListPreview(it) }
|
enrichQueuedOutboundUi(withoutSuperseded, conversationId),
|
||||||
?.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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
suspend fun loadPublicMessages(): List<Message> =
|
||||||
* Full history for a conversation (unbounded). Prefer [loadRecentPublicMessages] when opening UI.
|
loadMessages(conversationIdForPublic())
|
||||||
*/
|
|
||||||
suspend fun loadPublicMessages(): List<Message> {
|
|
||||||
return loadMessages(conversationIdForPublic())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
|
||||||
* Most recent [limit] messages for public chat, chronological (oldest → newest).
|
loadRecentMessages(conversationIdForPublic(), limit)
|
||||||
* 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 replacePublicMessages(messages: List<Message>) {
|
suspend fun replacePublicMessages(messages: List<Message>) {
|
||||||
val pending = loadPendingMessages(conversationIdForPublic())
|
val convId = conversationIdForPublic()
|
||||||
|
val pending = loadPendingMessages(convId)
|
||||||
val stillPending = pending.filter { p ->
|
val stillPending = pending.filter { p ->
|
||||||
val cid = p.client_message_id
|
val cid = p.client_message_id
|
||||||
cid == null || messages.none { it.client_message_id == cid }
|
cid == null || messages.none { it.client_message_id == cid }
|
||||||
}
|
}
|
||||||
val merged = (messages + stillPending)
|
val merged = dedupeMessagesByClientId(messages + stillPending)
|
||||||
.distinctBy { msg ->
|
.let { sortMessagesForChatDisplay(it) }
|
||||||
when {
|
replaceMessages(convId, merged)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun clearPublicMessages() {
|
suspend fun clearPublicMessages() {
|
||||||
clearConversationMessages(conversationIdForPublic())
|
clearConversationMessages(conversationIdForPublic())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadDmMessages(otherUserId: Int): List<Message> {
|
suspend fun loadDmMessages(otherUserId: Int): List<Message> =
|
||||||
return loadMessages(conversationIdForDm(otherUserId))
|
loadMessages(conversationIdForDm(otherUserId))
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun clearDmMessages(otherUserId: Int) {
|
suspend fun clearDmMessages(otherUserId: Int) {
|
||||||
clearConversationMessages(conversationIdForDm(otherUserId))
|
clearConversationMessages(conversationIdForDm(otherUserId))
|
||||||
@@ -118,22 +95,50 @@ object MessageCacheStore {
|
|||||||
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
|
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
|
||||||
val convId = conversationIdForDm(otherUserId)
|
val convId = conversationIdForDm(otherUserId)
|
||||||
val pending = loadPendingMessages(convId)
|
val pending = loadPendingMessages(convId)
|
||||||
val stillPending = pending.filter { p ->
|
val stillPending = filterStillPendingForReplace(convId, pending, messages)
|
||||||
val cid = p.client_message_id
|
val before = messages + stillPending
|
||||||
cid == null || messages.none { it.client_message_id == cid }
|
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)
|
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) {
|
suspend fun upsertPublicMessage(message: Message) {
|
||||||
upsertSingle(conversationIdForPublic(), message)
|
upsertSingle(conversationIdForPublic(), message)
|
||||||
}
|
}
|
||||||
@@ -151,6 +156,14 @@ object MessageCacheStore {
|
|||||||
deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId)
|
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) {
|
suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) {
|
||||||
confirmMessage(conversationIdForPublic(), clientMessageId, confirmed)
|
confirmMessage(conversationIdForPublic(), clientMessageId, confirmed)
|
||||||
}
|
}
|
||||||
@@ -159,138 +172,43 @@ object MessageCacheStore {
|
|||||||
confirmMessage(conversationIdForDm(otherUserId), clientMessageId, confirmed)
|
confirmMessage(conversationIdForDm(otherUserId), clientMessageId, confirmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun clearConversationMessages(conversationId: String) {
|
/** After decrypt, persist [localPreviewUri] so reopen skips network. */
|
||||||
withContext(Dispatchers.Default) {
|
suspend fun patchDmMessageLocalPreview(
|
||||||
db.messageDatabaseQueries.deleteMessagesForConversation(conversationId)
|
otherUserId: Int,
|
||||||
}
|
messageId: Int,
|
||||||
}
|
localPreviewUri: String,
|
||||||
|
) {
|
||||||
private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) {
|
if (messageId <= 0 || !DecryptedImageCache.isDecryptedImageCacheUri(localPreviewUri)) return
|
||||||
withContext(Dispatchers.Default) {
|
val convId = conversationIdForDm(otherUserId)
|
||||||
db.messageDatabaseQueries.deleteMessageByClientMessageId(conversationId, clientMessageId)
|
val iid = instanceId()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun upsertSingle(conversationId: String, msg: Message) {
|
|
||||||
withContext(Dispatchers.Default) {
|
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(
|
db.messageDatabaseQueries.upsertMessage(
|
||||||
|
instanceId = iid,
|
||||||
id = msg.id.toLong(),
|
id = msg.id.toLong(),
|
||||||
conversationId = conversationId,
|
conversationId = convId,
|
||||||
userId = msg.user_id.toLong(),
|
userId = msg.user_id.toLong(),
|
||||||
content = msg.content,
|
content = encodePersistedDmMessage(msg),
|
||||||
timestamp = msg.timestamp,
|
timestamp = msg.timestamp,
|
||||||
isRead = if (msg.is_read) 1L else 0L,
|
isRead = if (msg.is_read) 1L else 0L,
|
||||||
isEdited = if (msg.is_edited) 1L else 0L,
|
isEdited = if (msg.is_edited) 1L else 0L,
|
||||||
replyToId = msg.reply_to?.id?.toLong(),
|
replyToId = msg.reply_to?.id?.toLong(),
|
||||||
clientMessageId = msg.client_message_id,
|
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) {
|
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
|
||||||
|
val iid = instanceId()
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
db.messageDatabaseQueries.markMessageDeleted(
|
db.messageDatabaseQueries.markMessageDeleted(
|
||||||
|
instanceId = iid,
|
||||||
id = messageId.toLong(),
|
id = messageId.toLong(),
|
||||||
conversationId = conversationId
|
conversationId = conversationId
|
||||||
)
|
)
|
||||||
@@ -298,6 +216,7 @@ object MessageCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun replaceDmConversations(conversations: List<DmConversation>) {
|
suspend fun replaceDmConversations(conversations: List<DmConversation>) {
|
||||||
|
val iid = instanceId()
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
db.messageDatabaseQueries.transaction {
|
db.messageDatabaseQueries.transaction {
|
||||||
conversations.forEach { conv ->
|
conversations.forEach { conv ->
|
||||||
@@ -305,7 +224,7 @@ object MessageCacheStore {
|
|||||||
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
?: conv.user.username.trim()
|
?: conv.user.username.trim()
|
||||||
val recent = db.messageDatabaseQueries
|
val recent = db.messageDatabaseQueries
|
||||||
.selectRecentMessagesByConversation(conversationId, 1)
|
.selectRecentMessagesByConversation(iid, conversationId, 1)
|
||||||
.executeAsList()
|
.executeAsList()
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
val rawPreview = recent?.content?.orEmpty()?.trim().orEmpty()
|
val rawPreview = recent?.content?.orEmpty()?.trim().orEmpty()
|
||||||
@@ -313,6 +232,7 @@ object MessageCacheStore {
|
|||||||
?.let { truncateDmListPreview(it) }
|
?.let { truncateDmListPreview(it) }
|
||||||
?.takeIf { it.isNotEmpty() }
|
?.takeIf { it.isNotEmpty() }
|
||||||
db.messageDatabaseQueries.upsertConversation(
|
db.messageDatabaseQueries.upsertConversation(
|
||||||
|
instanceId = iid,
|
||||||
id = conversationId,
|
id = conversationId,
|
||||||
type = "dm",
|
type = "dm",
|
||||||
otherUserId = conv.user.id.toLong(),
|
otherUserId = conv.user.id.toLong(),
|
||||||
@@ -330,7 +250,7 @@ object MessageCacheStore {
|
|||||||
suspend fun loadCachedDmConversations(): List<CachedConversation> =
|
suspend fun loadCachedDmConversations(): List<CachedConversation> =
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
db.messageDatabaseQueries
|
db.messageDatabaseQueries
|
||||||
.selectConversations()
|
.selectConversationsForInstance(instanceId())
|
||||||
.executeAsList()
|
.executeAsList()
|
||||||
.filter { row: Conversation -> row.type == "dm" }
|
.filter { row: Conversation -> row.type == "dm" }
|
||||||
.map { row: Conversation ->
|
.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
|
expect fun provideMessageDatabaseDriver(): SqlDriver
|
||||||
|
|
||||||
object MessageDatabaseProvider {
|
object MessageDatabaseProvider {
|
||||||
val database: MessageDatabase by lazy {
|
private var driver: SqlDriver? = null
|
||||||
MessageDatabase(provideMessageDatabaseDriver())
|
private var databaseHolder: MessageDatabase? = null
|
||||||
|
|
||||||
|
val database: MessageDatabase
|
||||||
|
get() = withMessageDatabaseLock {
|
||||||
|
databaseHolder ?: MessageDatabase(openDriverLocked()).also { databaseHolder = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the open SQLite connection. Required before deleting `cacheDir/fromchat/`
|
||||||
|
* (otherwise the next write hits SQLITE_READONLY_DBMOVED).
|
||||||
|
*/
|
||||||
|
fun closeAndReset() {
|
||||||
|
withMessageDatabaseLock {
|
||||||
|
databaseHolder = null
|
||||||
|
runCatching { driver?.close() }
|
||||||
|
driver = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Runs [block] once; on a stale connection after cache wipe, resets and retries. */
|
||||||
|
internal fun <T> withDatabaseRecover(block: () -> T): T {
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (!isStaleSqliteConnection(e)) throw e
|
||||||
|
closeAndReset()
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isStaleSqliteConnection(throwable: Throwable): Boolean {
|
||||||
|
var current: Throwable? = throwable
|
||||||
|
while (current != null) {
|
||||||
|
val message = current.message?.lowercase().orEmpty()
|
||||||
|
if (message.contains("readonly") && message.contains("database")) return true
|
||||||
|
if (message.contains("sqlite_readonly_dbmoved")) return true
|
||||||
|
current = current.cause
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rebindUnboundPartition(targetInstanceId: String) {
|
||||||
|
runCatching {
|
||||||
|
withMessageDatabaseLock {
|
||||||
|
rebindUnboundMigrationInstance(openDriverLocked(), targetInstanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caller must hold the message-database lock via [withMessageDatabaseLock]. */
|
||||||
|
private fun openDriverLocked(): SqlDriver {
|
||||||
|
driver?.let { return it }
|
||||||
|
return provideMessageDatabaseDriver().also {
|
||||||
|
ensureMessageDatabaseSchema(it)
|
||||||
|
driver = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package ru.fromchat.api.db
|
||||||
|
|
||||||
|
import app.cash.sqldelight.db.QueryResult
|
||||||
|
import app.cash.sqldelight.db.SqlDriver
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import ru.fromchat.core.Settings
|
||||||
|
import ru.fromchat.core.instance.isValidInstanceUuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aligns on-disk SQLite with [MessageDatabaseExpectedSchema] by structural diff only
|
||||||
|
* (no user_version / schema version buckets). Full wipe is last resort.
|
||||||
|
*/
|
||||||
|
internal const val UNBOUND_MIGRATION_INSTANCE_ID = "00000000-0000-4000-8000-000000000001"
|
||||||
|
|
||||||
|
internal fun ensureMessageDatabaseSchema(driver: SqlDriver) {
|
||||||
|
runCatching { migrateMessageDatabaseSchema(driver) }
|
||||||
|
.onFailure { recreateMessageDatabaseSchema(driver) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reassign rows stored under [UNBOUND_MIGRATION_INSTANCE_ID] when a real instance id is known. */
|
||||||
|
internal fun rebindUnboundMigrationInstance(driver: SqlDriver, targetInstanceId: String) {
|
||||||
|
val target = targetInstanceId.trim()
|
||||||
|
if (target.isEmpty() || !isValidInstanceUuid(target)) return
|
||||||
|
if (target == UNBOUND_MIGRATION_INSTANCE_ID) return
|
||||||
|
if (!tableExists(driver, "message")) return
|
||||||
|
val tablesWithInstance = listOf(
|
||||||
|
"message",
|
||||||
|
"conversation",
|
||||||
|
"attachment",
|
||||||
|
"outbox",
|
||||||
|
"profile_cache",
|
||||||
|
)
|
||||||
|
inTransaction(driver) {
|
||||||
|
for (table in tablesWithInstance) {
|
||||||
|
if (!tableExists(driver, table)) continue
|
||||||
|
if (!columnExists(driver, table, "instanceId")) continue
|
||||||
|
driver.execute(
|
||||||
|
identifier = null,
|
||||||
|
sql = "UPDATE $table SET instanceId = ? WHERE instanceId = ?",
|
||||||
|
parameters = 2,
|
||||||
|
binders = {
|
||||||
|
bindString(0, target)
|
||||||
|
bindString(1, UNBOUND_MIGRATION_INSTANCE_ID)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ColumnAdd(val table: String, val column: String, val definition: String)
|
||||||
|
|
||||||
|
private enum class PartitionedTableRebuild {
|
||||||
|
Message,
|
||||||
|
Conversation,
|
||||||
|
Attachment,
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class SchemaDiff(
|
||||||
|
val createTables: List<String> = emptyList(),
|
||||||
|
val partitionRebuilds: List<PartitionedTableRebuild> = emptyList(),
|
||||||
|
val addColumns: List<ColumnAdd> = emptyList(),
|
||||||
|
val ensureIndexes: List<Pair<String, String>> = emptyList(),
|
||||||
|
val dropIndexes: Set<String> = emptySet(),
|
||||||
|
val unrecoverableReason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun migrateMessageDatabaseSchema(driver: SqlDriver) {
|
||||||
|
val diff = computeSchemaDiff(driver)
|
||||||
|
diff.unrecoverableReason?.let { error(it) }
|
||||||
|
if (diff.isNoOp()) {
|
||||||
|
normalizeLegacyConversationIds(driver)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applySchemaDiff(driver, diff)
|
||||||
|
normalizeLegacyConversationIds(driver)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SchemaDiff.isNoOp(): Boolean =
|
||||||
|
createTables.isEmpty() &&
|
||||||
|
partitionRebuilds.isEmpty() &&
|
||||||
|
addColumns.isEmpty() &&
|
||||||
|
ensureIndexes.isEmpty() &&
|
||||||
|
dropIndexes.isEmpty()
|
||||||
|
|
||||||
|
private fun computeSchemaDiff(driver: SqlDriver): SchemaDiff {
|
||||||
|
val tables = listTables(driver)
|
||||||
|
if (tables.isEmpty()) {
|
||||||
|
return SchemaDiff(
|
||||||
|
createTables = MessageDatabaseExpectedSchema.managedTables.toList(),
|
||||||
|
ensureIndexes = MessageDatabaseExpectedSchema.requiredIndexes.map { it.key to it.value },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var createTables = mutableListOf<String>()
|
||||||
|
var partitionRebuilds = mutableListOf<PartitionedTableRebuild>()
|
||||||
|
var addColumns = mutableListOf<ColumnAdd>()
|
||||||
|
var unrecoverable: String? = null
|
||||||
|
|
||||||
|
fun fail(reason: String) {
|
||||||
|
if (unrecoverable == null) unrecoverable = reason
|
||||||
|
}
|
||||||
|
|
||||||
|
for (tableName in MessageDatabaseExpectedSchema.managedTables) {
|
||||||
|
if (unrecoverable != null) break
|
||||||
|
val required = MessageDatabaseExpectedSchema.requiredColumns[tableName] ?: continue
|
||||||
|
if (!tables.contains(tableName)) {
|
||||||
|
createTables.add(tableName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val actual = columnNames(driver, tableName)
|
||||||
|
if (tableName in MessageDatabaseExpectedSchema.partitionedTables && !actual.contains("instanceId")) {
|
||||||
|
if (!legacyPartitionCorePresent(tableName, actual)) {
|
||||||
|
fail("$tableName exists but lacks instanceId and recognizable legacy columns")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
partitionRebuilds.add(tableName.toPartitionRebuild())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partitionCorePresent(tableName, actual)) {
|
||||||
|
fail("$tableName is missing required columns: ${required.keys - actual}")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((column, definition) in required) {
|
||||||
|
if (actual.contains(column)) continue
|
||||||
|
if (column == "instanceId") continue
|
||||||
|
addColumns.add(ColumnAdd(tableName, column, definition))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unrecoverable != null) {
|
||||||
|
return SchemaDiff(unrecoverableReason = unrecoverable)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ensureIndexes = MessageDatabaseExpectedSchema.requiredIndexes
|
||||||
|
.filterNot { (name, _) -> indexExists(driver, name) }
|
||||||
|
.map { it.key to it.value }
|
||||||
|
|
||||||
|
val dropIndexes = MessageDatabaseExpectedSchema.obsoleteIndexes
|
||||||
|
.filter { indexExists(driver, it) }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
return SchemaDiff(
|
||||||
|
createTables = createTables,
|
||||||
|
partitionRebuilds = partitionRebuilds.distinct(),
|
||||||
|
addColumns = addColumns,
|
||||||
|
ensureIndexes = ensureIndexes,
|
||||||
|
dropIndexes = dropIndexes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toPartitionRebuild(): PartitionedTableRebuild =
|
||||||
|
when (this) {
|
||||||
|
"message" -> PartitionedTableRebuild.Message
|
||||||
|
"conversation" -> PartitionedTableRebuild.Conversation
|
||||||
|
"attachment" -> PartitionedTableRebuild.Attachment
|
||||||
|
else -> error("Not a partitioned table: $this")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun legacyPartitionCorePresent(table: String, actual: Set<String>): Boolean =
|
||||||
|
when (table) {
|
||||||
|
"message" ->
|
||||||
|
actual.containsAll(
|
||||||
|
setOf(
|
||||||
|
"id",
|
||||||
|
"conversationId",
|
||||||
|
"userId",
|
||||||
|
"content",
|
||||||
|
"timestamp",
|
||||||
|
"isRead",
|
||||||
|
"isEdited",
|
||||||
|
"deletedFlag",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"conversation" -> actual.containsAll(setOf("id", "type"))
|
||||||
|
"attachment" -> actual.containsAll(setOf("id", "messageId", "conversationId", "status"))
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun partitionCorePresent(table: String, actual: Set<String>): Boolean {
|
||||||
|
val required = MessageDatabaseExpectedSchema.requiredColumns[table] ?: return true
|
||||||
|
val core =
|
||||||
|
when (table) {
|
||||||
|
"message" ->
|
||||||
|
setOf(
|
||||||
|
"instanceId",
|
||||||
|
"id",
|
||||||
|
"conversationId",
|
||||||
|
"userId",
|
||||||
|
"content",
|
||||||
|
"timestamp",
|
||||||
|
"isRead",
|
||||||
|
"isEdited",
|
||||||
|
"deletedFlag",
|
||||||
|
)
|
||||||
|
"conversation" -> setOf("instanceId", "id", "type")
|
||||||
|
"attachment" -> setOf("instanceId", "id", "messageId", "conversationId", "status")
|
||||||
|
"server_binding" -> setOf("configKey", "activeInstanceId")
|
||||||
|
"instance_registry" -> setOf("instanceId", "firstSeenAt", "lastSeenAt")
|
||||||
|
"outbox" ->
|
||||||
|
setOf("instanceId", "clientMessageId", "conversationId", "kind", "payloadJson", "retryCount")
|
||||||
|
"profile_cache" -> setOf("instanceId", "userId", "json")
|
||||||
|
else -> required.keys
|
||||||
|
}
|
||||||
|
return core.all { actual.contains(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applySchemaDiff(driver: SqlDriver, diff: SchemaDiff) {
|
||||||
|
val instanceId = migrationInstanceId()
|
||||||
|
inTransaction(driver) {
|
||||||
|
for (rebuild in diff.partitionRebuilds) {
|
||||||
|
when (rebuild) {
|
||||||
|
PartitionedTableRebuild.Message -> rebuildLegacyMessageTable(driver, instanceId)
|
||||||
|
PartitionedTableRebuild.Conversation -> rebuildLegacyConversationTable(driver, instanceId)
|
||||||
|
PartitionedTableRebuild.Attachment -> rebuildLegacyAttachmentTable(driver, instanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (table in diff.createTables) {
|
||||||
|
val sql = MessageDatabaseExpectedSchema.tableCreateSql[table]
|
||||||
|
?: error("Missing CREATE SQL for $table")
|
||||||
|
driver.execute(identifier = null, sql = sql, parameters = 0)
|
||||||
|
}
|
||||||
|
for (add in diff.addColumns) {
|
||||||
|
if (!tableExists(driver, add.table)) continue
|
||||||
|
if (columnExists(driver, add.table, add.column)) continue
|
||||||
|
driver.execute(
|
||||||
|
identifier = null,
|
||||||
|
sql = "ALTER TABLE ${add.table} ADD COLUMN ${add.column} ${add.definition}",
|
||||||
|
parameters = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (indexName in diff.dropIndexes) {
|
||||||
|
driver.execute(identifier = null, sql = "DROP INDEX IF EXISTS $indexName", parameters = 0)
|
||||||
|
}
|
||||||
|
for ((_, createSql) in diff.ensureIndexes) {
|
||||||
|
driver.execute(identifier = null, sql = createSql, parameters = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyExpectedSchema(driver: SqlDriver) {
|
||||||
|
for (table in MessageDatabaseExpectedSchema.managedTables) {
|
||||||
|
val sql = MessageDatabaseExpectedSchema.tableCreateSql[table] ?: continue
|
||||||
|
driver.execute(identifier = null, sql = sql, parameters = 0)
|
||||||
|
}
|
||||||
|
for ((_, createSql) in MessageDatabaseExpectedSchema.requiredIndexes) {
|
||||||
|
driver.execute(identifier = null, sql = createSql, parameters = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeLegacyConversationIds(driver: SqlDriver) {
|
||||||
|
if (!tableExists(driver, "message")) return
|
||||||
|
runCatching {
|
||||||
|
driver.execute(
|
||||||
|
identifier = null,
|
||||||
|
sql = "UPDATE message SET conversationId = '-1' WHERE conversationId = 'public'",
|
||||||
|
parameters = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!tableExists(driver, "conversation")) return
|
||||||
|
runCatching {
|
||||||
|
driver.execute(
|
||||||
|
identifier = null,
|
||||||
|
sql = "UPDATE conversation SET id = '-1' WHERE id = 'public'",
|
||||||
|
parameters = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun migrationInstanceId(): String {
|
||||||
|
val known = runBlocking { Settings.lastKnownServerInstanceId.trim() }
|
||||||
|
if (known.isNotEmpty() && isValidInstanceUuid(known)) return known
|
||||||
|
return UNBOUND_MIGRATION_INSTANCE_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebuildLegacyMessageTable(driver: SqlDriver, instanceId: String) {
|
||||||
|
driver.execute(identifier = null, sql = "DROP INDEX IF EXISTS message_conversation_index", parameters = 0)
|
||||||
|
driver.execute(
|
||||||
|
identifier = null,
|
||||||
|
sql = """
|
||||||
|
CREATE TABLE message__migrate (
|
||||||
|
instanceId TEXT NOT NULL,
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
conversationId TEXT NOT NULL,
|
||||||
|
userId INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
isRead INTEGER NOT NULL,
|
||||||
|
isEdited INTEGER NOT NULL,
|
||||||
|
replyToId INTEGER,
|
||||||
|
clientMessageId TEXT,
|
||||||
|
deletedFlag INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sendStatus TEXT,
|
||||||
|
PRIMARY KEY (instanceId, conversationId, id)
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
parameters = 0,
|
||||||
|
)
|
||||||
|
driver.execute(
|
||||||
|
identifier = null,
|
||||||
|
sql = """
|
||||||
|
INSERT INTO message__migrate(
|
||||||
|
instanceId, id, conversationId, userId, content, timestamp,
|
||||||
|
isRead, isEdited, replyToId, clientMessageId, deletedFlag, sendStatus
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
?, id,
|
||||||
|
CASE WHEN conversationId = 'public' THEN '-1' ELSE conversationId END,
|
||||||
|
userId, content, timestamp, isRead, isEdited, replyToId, clientMessageId, deletedFlag,
|
||||||
|
CASE WHEN id < 0 THEN 'pending' ELSE 'sent' END
|
||||||
|
FROM message
|
||||||
|
""".trimIndent(),
|
||||||
|
parameters = 1,
|
||||||
|
binders = { bindString(0, instanceId) },
|
||||||
|
)
|
||||||
|
driver.execute(identifier = null, sql = "DROP TABLE message", parameters = 0)
|
||||||
|
driver.execute(identifier = null, sql = "ALTER TABLE message__migrate RENAME TO message", parameters = 0)
|
||||||
|
driver.execute(
|
||||||
|
identifier = null,
|
||||||
|
sql = MessageDatabaseExpectedSchema.requiredIndexes.getValue("message_instance_conversation_index"),
|
||||||
|
parameters = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebuildLegacyConversationTable(driver: SqlDriver, instanceId: String) {
|
||||||
|
driver.execute(
|
||||||
|
identifier = null,
|
||||||
|
sql = """
|
||||||
|
CREATE TABLE conversation__migrate (
|
||||||
|
instanceId TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
otherUserId INTEGER,
|
||||||
|
displayName TEXT,
|
||||||
|
lastMessageId INTEGER,
|
||||||
|
lastMessagePreview TEXT,
|
||||||
|
unreadCount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updatedAt TEXT,
|
||||||
|
PRIMARY KEY (instanceId, id)
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
parameters = 0,
|
||||||
|
)
|
||||||
|
driver.execute(
|
||||||
|
identifier = null,
|
||||||
|
sql = """
|
||||||
|
INSERT INTO conversation__migrate(
|
||||||
|
instanceId, id, type, otherUserId, displayName,
|
||||||
|
lastMessageId, lastMessagePreview, unreadCount, updatedAt
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
?,
|
||||||
|
CASE WHEN id = 'public' THEN '-1' ELSE id END,
|
||||||
|
type, otherUserId, displayName,
|
||||||
|
lastMessageId, lastMessagePreview, unreadCount, updatedAt
|
||||||
|
FROM conversation
|
||||||
|
""".trimIndent(),
|
||||||
|
parameters = 1,
|
||||||
|
binders = { bindString(0, instanceId) },
|
||||||
|
)
|
||||||
|
driver.execute(identifier = null, sql = "DROP TABLE conversation", parameters = 0)
|
||||||
|
driver.execute(identifier = null, sql = "ALTER TABLE conversation__migrate RENAME TO conversation", parameters = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebuildLegacyAttachmentTable(driver: SqlDriver, instanceId: String) {
|
||||||
|
driver.execute(
|
||||||
|
identifier = null,
|
||||||
|
sql = """
|
||||||
|
CREATE TABLE attachment__migrate (
|
||||||
|
instanceId TEXT NOT NULL,
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
messageId INTEGER NOT NULL,
|
||||||
|
conversationId TEXT NOT NULL,
|
||||||
|
remotePath TEXT,
|
||||||
|
localPath TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
blurhash TEXT,
|
||||||
|
aspectRatio REAL,
|
||||||
|
size INTEGER,
|
||||||
|
bytesTransferred INTEGER NOT NULL DEFAULT 0,
|
||||||
|
clientMessageId TEXT,
|
||||||
|
PRIMARY KEY (instanceId, id, messageId, conversationId)
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
parameters = 0,
|
||||||
|
)
|
||||||
|
driver.execute(
|
||||||
|
identifier = null,
|
||||||
|
sql = """
|
||||||
|
INSERT INTO attachment__migrate(
|
||||||
|
instanceId, id, messageId, conversationId, remotePath, localPath,
|
||||||
|
status, blurhash, aspectRatio, size, bytesTransferred, clientMessageId
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
?, id, messageId,
|
||||||
|
CASE WHEN conversationId = 'public' THEN '-1' ELSE conversationId END,
|
||||||
|
remotePath, localPath, status, blurhash, aspectRatio, size, 0, NULL
|
||||||
|
FROM attachment
|
||||||
|
""".trimIndent(),
|
||||||
|
parameters = 1,
|
||||||
|
binders = { bindString(0, instanceId) },
|
||||||
|
)
|
||||||
|
driver.execute(identifier = null, sql = "DROP TABLE attachment", parameters = 0)
|
||||||
|
driver.execute(identifier = null, sql = "ALTER TABLE attachment__migrate RENAME TO attachment", parameters = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recreateMessageDatabaseSchema(driver: SqlDriver) {
|
||||||
|
val drops = listOf(
|
||||||
|
"DROP INDEX IF EXISTS message_instance_conversation_index",
|
||||||
|
"DROP INDEX IF EXISTS message_conversation_index",
|
||||||
|
"DROP TABLE IF EXISTS message",
|
||||||
|
"DROP TABLE IF EXISTS message__migrate",
|
||||||
|
"DROP TABLE IF EXISTS attachment",
|
||||||
|
"DROP TABLE IF EXISTS attachment__migrate",
|
||||||
|
"DROP TABLE IF EXISTS outbox",
|
||||||
|
"DROP TABLE IF EXISTS profile_cache",
|
||||||
|
"DROP TABLE IF EXISTS conversation",
|
||||||
|
"DROP TABLE IF EXISTS conversation__migrate",
|
||||||
|
"DROP TABLE IF EXISTS server_binding",
|
||||||
|
"DROP TABLE IF EXISTS instance_registry",
|
||||||
|
)
|
||||||
|
for (sql in drops) {
|
||||||
|
driver.execute(identifier = null, sql = sql, parameters = 0)
|
||||||
|
}
|
||||||
|
applyExpectedSchema(driver)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inTransaction(driver: SqlDriver, block: () -> Unit) {
|
||||||
|
driver.execute(identifier = null, sql = "BEGIN IMMEDIATE", parameters = 0)
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
driver.execute(identifier = null, sql = "COMMIT", parameters = 0)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
runCatching { driver.execute(identifier = null, sql = "ROLLBACK", parameters = 0) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listTables(driver: SqlDriver): Set<String> {
|
||||||
|
val names = mutableSetOf<String>()
|
||||||
|
driver.executeQuery(
|
||||||
|
identifier = null,
|
||||||
|
sql = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
||||||
|
mapper = { cursor ->
|
||||||
|
while (cursor.next().value) {
|
||||||
|
names.add(cursor.getString(0)!!)
|
||||||
|
}
|
||||||
|
QueryResult.Value(Unit)
|
||||||
|
},
|
||||||
|
parameters = 0,
|
||||||
|
)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun columnNames(driver: SqlDriver, table: String): Set<String> {
|
||||||
|
val names = mutableSetOf<String>()
|
||||||
|
driver.executeQuery(
|
||||||
|
identifier = null,
|
||||||
|
sql = "PRAGMA table_info($table)",
|
||||||
|
mapper = { cursor ->
|
||||||
|
while (cursor.next().value) {
|
||||||
|
names.add(cursor.getString(1)!!)
|
||||||
|
}
|
||||||
|
QueryResult.Value(Unit)
|
||||||
|
},
|
||||||
|
parameters = 0,
|
||||||
|
)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tableExists(driver: SqlDriver, table: String): Boolean =
|
||||||
|
listTables(driver).contains(table)
|
||||||
|
|
||||||
|
private fun columnExists(driver: SqlDriver, table: String, column: String): Boolean =
|
||||||
|
columnNames(driver, table).contains(column)
|
||||||
|
|
||||||
|
private fun indexExists(driver: SqlDriver, indexName: String): Boolean =
|
||||||
|
driver.executeQuery(
|
||||||
|
identifier = null,
|
||||||
|
sql = "SELECT 1 FROM sqlite_master WHERE type='index' AND name=? LIMIT 1",
|
||||||
|
mapper = { cursor -> QueryResult.Value(cursor.next().value) },
|
||||||
|
parameters = 1,
|
||||||
|
binders = { bindString(0, indexName) },
|
||||||
|
).value
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package ru.fromchat.api.db
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.core.cache.CacheContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instance-scoped message access for UI and send pipeline.
|
||||||
|
*/
|
||||||
|
object MessageRepository {
|
||||||
|
private fun activeInstance(): String = CacheContext.requireActiveInstanceId()
|
||||||
|
|
||||||
|
fun observeMessages(conversationId: String): Flow<List<Message>> =
|
||||||
|
MessageCacheStore.observeMessages(activeInstance(), conversationId)
|
||||||
|
|
||||||
|
fun observePublicMessages(): Flow<List<Message>> =
|
||||||
|
observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID))
|
||||||
|
|
||||||
|
fun observeDmMessages(otherUserId: Int): Flow<List<Message>> =
|
||||||
|
observeMessages(conversationIdForDm(otherUserId))
|
||||||
|
|
||||||
|
suspend fun loadPublicMessages(): List<Message> = MessageCacheStore.loadPublicMessages()
|
||||||
|
|
||||||
|
suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
|
||||||
|
MessageCacheStore.loadRecentPublicMessages(limit)
|
||||||
|
|
||||||
|
suspend fun replacePublicMessages(messages: List<Message>) =
|
||||||
|
MessageCacheStore.replacePublicMessages(messages)
|
||||||
|
|
||||||
|
suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message)
|
||||||
|
|
||||||
|
suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) =
|
||||||
|
MessageCacheStore.confirmPublicMessage(clientMessageId, confirmed)
|
||||||
|
|
||||||
|
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) =
|
||||||
|
MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId)
|
||||||
|
|
||||||
|
suspend fun markMessageDeleted(conversationId: String, messageId: Int) =
|
||||||
|
MessageCacheStore.markMessageDeleted(conversationId, messageId)
|
||||||
|
|
||||||
|
suspend fun markPublicMessageDeleted(messageId: Int) =
|
||||||
|
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
|
||||||
|
|
||||||
|
suspend fun loadDmMessages(otherUserId: Int): List<Message> =
|
||||||
|
MessageCacheStore.loadDmMessages(otherUserId)
|
||||||
|
|
||||||
|
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) =
|
||||||
|
MessageCacheStore.replaceDmMessages(otherUserId, messages)
|
||||||
|
|
||||||
|
suspend fun upsertDmMessage(otherUserId: Int, message: Message) =
|
||||||
|
MessageCacheStore.upsertDmMessage(otherUserId, message)
|
||||||
|
|
||||||
|
suspend fun confirmDmMessage(otherUserId: Int, clientMessageId: String, confirmed: Message) =
|
||||||
|
MessageCacheStore.confirmDmMessage(otherUserId, clientMessageId, confirmed)
|
||||||
|
|
||||||
|
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) =
|
||||||
|
MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId)
|
||||||
|
|
||||||
|
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) =
|
||||||
|
MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
|
||||||
|
|
||||||
|
suspend fun replaceDmConversations(conversations: List<ru.fromchat.api.DmConversation>) =
|
||||||
|
MessageCacheStore.replaceDmConversations(conversations)
|
||||||
|
|
||||||
|
suspend fun loadCachedDmConversations(): List<CachedConversation> =
|
||||||
|
MessageCacheStore.loadCachedDmConversations()
|
||||||
|
|
||||||
|
suspend fun clearAllCache() = MessageCacheStore.clearAll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package ru.fromchat.api.db
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import ru.fromchat.api.UserProfile
|
||||||
|
|
||||||
|
object ProfileCacheStore {
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun get(instanceId: String, userId: Int): UserProfile? = withContext(Dispatchers.Default) {
|
||||||
|
val id = instanceId.trim()
|
||||||
|
if (id.isEmpty()) return@withContext null
|
||||||
|
val raw = MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
|
.selectProfileCache(id, userId.toLong())
|
||||||
|
.executeAsOneOrNull() ?: return@withContext null
|
||||||
|
runCatching { json.decodeFromString(UserProfile.serializer(), raw) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun put(instanceId: String, profile: UserProfile) {
|
||||||
|
val id = instanceId.trim()
|
||||||
|
if (id.isEmpty()) return
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries.upsertProfileCache(
|
||||||
|
instanceId = id,
|
||||||
|
userId = profile.id.toLong(),
|
||||||
|
json = json.encodeToString(UserProfile.serializer(), profile),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun remove(instanceId: String, userId: Int) {
|
||||||
|
val id = instanceId.trim()
|
||||||
|
if (id.isEmpty()) return
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries.deleteProfileCache(
|
||||||
|
instanceId = id,
|
||||||
|
userId = userId.toLong(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadAllForInstance(instanceId: String): Map<Int, UserProfile> =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val id = instanceId.trim()
|
||||||
|
if (id.isEmpty()) return@withContext emptyMap()
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
|
.selectAllProfilesForInstance(id)
|
||||||
|
.executeAsList()
|
||||||
|
.mapNotNull { row ->
|
||||||
|
runCatching {
|
||||||
|
json.decodeFromString(UserProfile.serializer(), row.json) to row.userId.toInt()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
.associate { (profile, uid) -> uid to profile }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
package ru.fromchat.api.outbox
|
||||||
|
|
||||||
|
import com.pr0gramm3r101.utils.crypto.Base64
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.AttachmentUploadNotifier
|
||||||
|
import ru.fromchat.api.AttachmentUploadProgress
|
||||||
|
import ru.fromchat.api.DmUploadCompleteResponse
|
||||||
|
import ru.fromchat.api.SendDmFile
|
||||||
|
import ru.fromchat.api.db.MessageCacheStore
|
||||||
|
import ru.fromchat.api.db.MessageDatabaseProvider
|
||||||
|
import ru.fromchat.api.db.conversationIdForDm
|
||||||
|
import ru.fromchat.core.cache.OutboundFileUnavailableException
|
||||||
|
import ru.fromchat.core.cache.clearUploadSecretsOnly
|
||||||
|
import ru.fromchat.api.optimisticMessageIdForClientMessageId
|
||||||
|
import ru.fromchat.core.cache.isOutboundFileUnavailable
|
||||||
|
import ru.fromchat.ui.chat.AttachmentMediaLog
|
||||||
|
import ru.fromchat.ui.chat.clearOutboundImageCaches
|
||||||
|
import ru.fromchat.core.cache.loadEncryptedUploadBlob
|
||||||
|
import ru.fromchat.core.cache.loadUploadTransportCipherJson
|
||||||
|
import ru.fromchat.core.cache.readOutboundFileBytes
|
||||||
|
import ru.fromchat.core.cache.saveEncryptedUploadBlob
|
||||||
|
import ru.fromchat.core.cache.saveUploadTransportCipherJson
|
||||||
|
import ru.fromchat.core.cache.stageOutboundFileForUpload
|
||||||
|
import ru.fromchat.crypto.transport.TransportCiphertext
|
||||||
|
import ru.fromchat.crypto.transport.TransportCrypto
|
||||||
|
import ru.fromchat.db.Outbox
|
||||||
|
|
||||||
|
private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024
|
||||||
|
private const val DEFAULT_CHUNK_SIZE = 262_144
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class StoredTransportCipher(
|
||||||
|
val clientPublicKeyB64: String,
|
||||||
|
val nonceB64: String,
|
||||||
|
val ciphertextB64: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
object DmAttachmentOutboxHandler {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun outboxRowExists(instanceId: String, clientMessageId: String): Boolean =
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
|
.selectOutboxItem(instanceId, clientMessageId)
|
||||||
|
.executeAsOneOrNull() != null
|
||||||
|
|
||||||
|
private fun ensureStillQueued(instanceId: String, clientMessageId: String) {
|
||||||
|
if (!outboxRowExists(instanceId, clientMessageId)) {
|
||||||
|
AttachmentMediaLog.upload("cancelled_in_flight", "job" to clientMessageId)
|
||||||
|
throw kotlinx.coroutines.CancellationException("Upload cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun process(row: Outbox): Boolean {
|
||||||
|
if (row.kind != OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT) return false
|
||||||
|
val instanceId = row.instanceId
|
||||||
|
val payload = json.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson)
|
||||||
|
val clientMessageId = payload.clientMessageId.trim()
|
||||||
|
if (clientMessageId.isEmpty() || payload.recipientId <= 0) return true
|
||||||
|
if (!outboxRowExists(instanceId, clientMessageId)) return true
|
||||||
|
|
||||||
|
val conversationId = row.conversationId.ifBlank {
|
||||||
|
conversationIdForDm(payload.recipientId)
|
||||||
|
}
|
||||||
|
if (MessageCacheStore.hasSentMessageWithClientId(conversationId, clientMessageId)) {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem(
|
||||||
|
instanceId,
|
||||||
|
clientMessageId,
|
||||||
|
)
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries.deletePendingMessageByClientMessageId(
|
||||||
|
instanceId,
|
||||||
|
conversationId,
|
||||||
|
clientMessageId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
clearPrepared(instanceId, clientMessageId)
|
||||||
|
clearUploadSecretsOnly(instanceId, clientMessageId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val serverUploadId = arrayOf(payload.uploadId.trim())
|
||||||
|
return runCatching {
|
||||||
|
ensureStillQueued(instanceId, clientMessageId)
|
||||||
|
AttachmentUploadNotifier.emit(
|
||||||
|
AttachmentUploadProgress.Pending(clientMessageId, payload.filename),
|
||||||
|
messageLabel = payload.plaintext,
|
||||||
|
)
|
||||||
|
val stagedPayload = ensureStagedPayload(instanceId, row, payload)
|
||||||
|
ensureStillQueued(instanceId, clientMessageId)
|
||||||
|
val restoredPercent = uploadPercent(row.bytesUploaded, stagedPayload)
|
||||||
|
if (restoredPercent > 0) {
|
||||||
|
emitProgress(clientMessageId, restoredPercent, stagedPayload.filename, stagedPayload.plaintext)
|
||||||
|
} else {
|
||||||
|
emitProgress(clientMessageId, 0, stagedPayload.filename, stagedPayload.plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
val prepared = loadPrepared(instanceId, clientMessageId)
|
||||||
|
val encryptedBlob: ByteArray
|
||||||
|
val msgCipher: TransportCiphertext
|
||||||
|
var activePayload = stagedPayload
|
||||||
|
|
||||||
|
if (prepared != null) {
|
||||||
|
encryptedBlob = prepared.first
|
||||||
|
msgCipher = prepared.second
|
||||||
|
if (activePayload.encryptedFileSizeBytes <= 0L) {
|
||||||
|
activePayload = activePayload.copy(encryptedFileSizeBytes = encryptedBlob.size.toLong())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ensureStillQueued(instanceId, clientMessageId)
|
||||||
|
val bytes = readOutboundFileBytes(stagedPayload.fileUri)
|
||||||
|
OutgoingMessageCoordinator.abortDmServerUploadIfNeeded(stagedPayload.uploadId)
|
||||||
|
serverUploadId[0] = ""
|
||||||
|
activePayload = stagedPayload.copy(uploadId = "", encryptedFileSizeBytes = 0L)
|
||||||
|
persistPayloadProgress(instanceId, row, activePayload, bytesUploaded = 0L)
|
||||||
|
ensureStillQueued(instanceId, clientMessageId)
|
||||||
|
val transportKey = ApiClient.getTransportPublicKey()
|
||||||
|
val (freshCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret(
|
||||||
|
plaintext = stagedPayload.plaintext,
|
||||||
|
transportPublicKeyB64 = transportKey.publicKeyB64,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val blob = TransportCrypto.encryptFileForTransport(
|
||||||
|
fileBytes = bytes,
|
||||||
|
transportPublicKeyB64 = transportKey.publicKeyB64,
|
||||||
|
ephemeralSecretKey = ephemeralSecret,
|
||||||
|
)
|
||||||
|
encryptedBlob = blob
|
||||||
|
msgCipher = freshCipher
|
||||||
|
savePrepared(instanceId, clientMessageId, encryptedBlob, msgCipher)
|
||||||
|
activePayload = activePayload.copy(encryptedFileSizeBytes = encryptedBlob.size.toLong())
|
||||||
|
persistPayloadProgress(instanceId, row, activePayload, bytesUploaded = 0L)
|
||||||
|
} finally {
|
||||||
|
ephemeralSecret.fill(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureStillQueued(instanceId, clientMessageId)
|
||||||
|
if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) {
|
||||||
|
sendInline(activePayload, encryptedBlob, msgCipher)
|
||||||
|
} else {
|
||||||
|
sendResumable(
|
||||||
|
instanceId = instanceId,
|
||||||
|
row = row,
|
||||||
|
payload = activePayload,
|
||||||
|
encryptedBlob = encryptedBlob,
|
||||||
|
msgCipher = msgCipher,
|
||||||
|
serverUploadId = serverUploadId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ensureStillQueued(instanceId, clientMessageId)
|
||||||
|
|
||||||
|
clearPrepared(instanceId, clientMessageId)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
|
||||||
|
instanceId = instanceId,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
conversationId = row.conversationId,
|
||||||
|
kind = OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT_AWAITING_ACK,
|
||||||
|
payloadJson = row.payloadJson,
|
||||||
|
retryCount = row.retryCount,
|
||||||
|
nextAttemptAt = null,
|
||||||
|
bytesUploaded = row.bytesUploaded,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AttachmentUploadNotifier.emit(
|
||||||
|
AttachmentUploadProgress.Success(clientMessageId),
|
||||||
|
messageLabel = payload.plaintext,
|
||||||
|
)
|
||||||
|
true
|
||||||
|
}.getOrElse { error ->
|
||||||
|
if (error is kotlinx.coroutines.CancellationException) {
|
||||||
|
OutgoingMessageCoordinator.abortDmServerUploadIfNeeded(serverUploadId[0])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (error.isOutboundFileUnavailable()) {
|
||||||
|
AttachmentUploadNotifier.emit(
|
||||||
|
AttachmentUploadProgress.Failed(
|
||||||
|
jobId = clientMessageId,
|
||||||
|
error = error.message ?: "Attachment unavailable",
|
||||||
|
),
|
||||||
|
messageLabel = payload.plaintext,
|
||||||
|
)
|
||||||
|
runCatching {
|
||||||
|
clearOutboundImageCaches(
|
||||||
|
clientMessageId,
|
||||||
|
optimisticMessageIdForClientMessageId(clientMessageId),
|
||||||
|
)
|
||||||
|
OutgoingMessageCoordinator.cancelOutboundMessage(clientMessageId, row.conversationId)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
AttachmentUploadNotifier.emit(
|
||||||
|
AttachmentUploadProgress.Failed(
|
||||||
|
jobId = clientMessageId,
|
||||||
|
error = error.message ?: "Upload failed",
|
||||||
|
),
|
||||||
|
messageLabel = payload.plaintext,
|
||||||
|
)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendInline(
|
||||||
|
payload: DmAttachmentOutboxPayload,
|
||||||
|
encryptedBlob: ByteArray,
|
||||||
|
msgCipher: TransportCiphertext,
|
||||||
|
) {
|
||||||
|
val file = SendDmFile(
|
||||||
|
encryptedFileDataB64 = Base64.encode(encryptedBlob),
|
||||||
|
filename = payload.filename,
|
||||||
|
fileSize = encryptedBlob.size.toLong(),
|
||||||
|
)
|
||||||
|
ApiClient.sendDm(
|
||||||
|
recipientId = payload.recipientId,
|
||||||
|
plaintext = payload.plaintext,
|
||||||
|
clientMessageId = payload.clientMessageId,
|
||||||
|
replyToId = payload.replyToId,
|
||||||
|
transportFiles = listOf(file),
|
||||||
|
preparedTransport = msgCipher,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendResumable(
|
||||||
|
instanceId: String,
|
||||||
|
row: Outbox,
|
||||||
|
payload: DmAttachmentOutboxPayload,
|
||||||
|
encryptedBlob: ByteArray,
|
||||||
|
msgCipher: TransportCiphertext,
|
||||||
|
serverUploadId: Array<String>,
|
||||||
|
) {
|
||||||
|
var uploadId = payload.uploadId.trim().ifBlank { serverUploadId[0] }
|
||||||
|
try {
|
||||||
|
if (uploadId.isEmpty()) {
|
||||||
|
val init = ApiClient.initDmUpload(
|
||||||
|
filename = payload.filename,
|
||||||
|
totalSize = encryptedBlob.size.toLong(),
|
||||||
|
recipientId = payload.recipientId,
|
||||||
|
chunkSize = DEFAULT_CHUNK_SIZE,
|
||||||
|
)
|
||||||
|
uploadId = init.uploadId
|
||||||
|
serverUploadId[0] = uploadId
|
||||||
|
persistPayloadProgress(instanceId, row, payload.copy(uploadId = uploadId), row.bytesUploaded)
|
||||||
|
} else {
|
||||||
|
serverUploadId[0] = uploadId
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = row.bytesUploaded.toInt().coerceAtLeast(0)
|
||||||
|
val serverOffset = ApiClient.getDmUploadStatus(uploadId).offset.toInt().coerceAtLeast(0)
|
||||||
|
offset = maxOf(offset, serverOffset)
|
||||||
|
while (offset < encryptedBlob.size) {
|
||||||
|
ensureStillQueued(instanceId, payload.clientMessageId)
|
||||||
|
val nextOffset = minOf(offset + DEFAULT_CHUNK_SIZE, encryptedBlob.size)
|
||||||
|
val chunk = encryptedBlob.copyOfRange(offset, nextOffset)
|
||||||
|
ApiClient.uploadDmChunk(
|
||||||
|
uploadId = uploadId,
|
||||||
|
offset = offset.toLong(),
|
||||||
|
dataB64 = Base64.encode(chunk),
|
||||||
|
)
|
||||||
|
offset = nextOffset
|
||||||
|
val percent = ((offset.toDouble() / encryptedBlob.size.toDouble()) * 100.0).toInt()
|
||||||
|
emitProgress(payload.clientMessageId, percent, payload.filename, payload.plaintext)
|
||||||
|
persistPayloadProgress(
|
||||||
|
instanceId,
|
||||||
|
row,
|
||||||
|
payload.copy(uploadId = uploadId),
|
||||||
|
offset.toLong(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val completed: DmUploadCompleteResponse = ApiClient.completeDmUpload(uploadId)
|
||||||
|
ApiClient.sendDm(
|
||||||
|
recipientId = payload.recipientId,
|
||||||
|
plaintext = payload.plaintext,
|
||||||
|
clientMessageId = payload.clientMessageId,
|
||||||
|
replyToId = payload.replyToId,
|
||||||
|
uploadedFileIds = listOf(completed.fileId),
|
||||||
|
preparedTransport = msgCipher,
|
||||||
|
)
|
||||||
|
serverUploadId[0] = ""
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
OutgoingMessageCoordinator.abortDmServerUploadIfNeeded(uploadId)
|
||||||
|
serverUploadId[0] = ""
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureStagedPayload(
|
||||||
|
instanceId: String,
|
||||||
|
row: Outbox,
|
||||||
|
payload: DmAttachmentOutboxPayload,
|
||||||
|
): DmAttachmentOutboxPayload {
|
||||||
|
val staged = stageOutboundFileForUpload(instanceId, payload.clientMessageId, payload.fileUri)
|
||||||
|
if (staged.sizeBytes <= 0L) {
|
||||||
|
throw OutboundFileUnavailableException("Attachment file is empty or unavailable")
|
||||||
|
}
|
||||||
|
if (staged.uri == payload.fileUri && staged.sizeBytes == payload.fileSizeBytes) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
val updated = payload.copy(fileUri = staged.uri, fileSizeBytes = staged.sizeBytes)
|
||||||
|
persistPayloadProgress(instanceId, row, updated, row.bytesUploaded)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun persistPayloadProgress(
|
||||||
|
instanceId: String,
|
||||||
|
row: Outbox,
|
||||||
|
payload: DmAttachmentOutboxPayload,
|
||||||
|
bytesUploaded: Long,
|
||||||
|
) {
|
||||||
|
if (!outboxRowExists(instanceId, row.clientMessageId)) return
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
if (!outboxRowExists(instanceId, row.clientMessageId)) return@withContext
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
|
||||||
|
instanceId = instanceId,
|
||||||
|
clientMessageId = row.clientMessageId,
|
||||||
|
conversationId = row.conversationId,
|
||||||
|
kind = row.kind,
|
||||||
|
payloadJson = json.encodeToString(payload),
|
||||||
|
retryCount = row.retryCount,
|
||||||
|
nextAttemptAt = row.nextAttemptAt,
|
||||||
|
bytesUploaded = bytesUploaded,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadPrepared(
|
||||||
|
instanceId: String,
|
||||||
|
clientMessageId: String,
|
||||||
|
): Pair<ByteArray, TransportCiphertext>? {
|
||||||
|
val blob = loadEncryptedUploadBlob(instanceId, clientMessageId) ?: return null
|
||||||
|
val raw = loadUploadTransportCipherJson(instanceId, clientMessageId) ?: return null
|
||||||
|
return runCatching {
|
||||||
|
val stored = json.decodeFromString<StoredTransportCipher>(raw)
|
||||||
|
val cipher = TransportCiphertext(
|
||||||
|
clientPublicKeyB64 = stored.clientPublicKeyB64,
|
||||||
|
nonceB64 = stored.nonceB64,
|
||||||
|
ciphertextB64 = stored.ciphertextB64,
|
||||||
|
)
|
||||||
|
blob to cipher
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun savePrepared(
|
||||||
|
instanceId: String,
|
||||||
|
clientMessageId: String,
|
||||||
|
blob: ByteArray,
|
||||||
|
cipher: TransportCiphertext,
|
||||||
|
) {
|
||||||
|
saveEncryptedUploadBlob(instanceId, clientMessageId, blob)
|
||||||
|
val stored = StoredTransportCipher(
|
||||||
|
clientPublicKeyB64 = cipher.clientPublicKeyB64,
|
||||||
|
nonceB64 = cipher.nonceB64,
|
||||||
|
ciphertextB64 = cipher.ciphertextB64,
|
||||||
|
)
|
||||||
|
saveUploadTransportCipherJson(instanceId, clientMessageId, json.encodeToString(stored))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun clearPrepared(instanceId: String, clientMessageId: String) {
|
||||||
|
clearUploadSecretsOnly(instanceId, clientMessageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uploadPercent(bytesUploaded: Long, payload: DmAttachmentOutboxPayload): Int {
|
||||||
|
val total = when {
|
||||||
|
payload.encryptedFileSizeBytes > 0L -> payload.encryptedFileSizeBytes
|
||||||
|
payload.fileSizeBytes > 0L -> payload.fileSizeBytes
|
||||||
|
else -> 0L
|
||||||
|
}
|
||||||
|
if (total <= 0L || bytesUploaded <= 0L) return 0
|
||||||
|
return ((bytesUploaded.toDouble() / total.toDouble()) * 100.0).toInt().coerceIn(0, 99)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitProgress(
|
||||||
|
jobId: String,
|
||||||
|
percent: Int,
|
||||||
|
filename: String? = null,
|
||||||
|
messageLabel: String? = null,
|
||||||
|
) {
|
||||||
|
AttachmentUploadNotifier.emit(
|
||||||
|
AttachmentUploadProgress.InProgress(
|
||||||
|
jobId = jobId,
|
||||||
|
percent = percent.coerceIn(0, 100),
|
||||||
|
filename = filename,
|
||||||
|
),
|
||||||
|
messageLabel = messageLabel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun hasPendingAttachmentWork(instanceId: String): Boolean =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
|
.selectPendingOutboxForInstance(instanceId)
|
||||||
|
.executeAsList()
|
||||||
|
.any { it.kind == OutgoingMessageCoordinator.KIND_SEND_DM_ATTACHMENT }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package ru.fromchat.api.outbox
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import ru.fromchat.api.SendDmFile
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PublicOutboxPayload(
|
||||||
|
val content: String,
|
||||||
|
val replyToId: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DmOutboxPayload(
|
||||||
|
val recipientId: Int,
|
||||||
|
val plaintext: String,
|
||||||
|
val clientMessageId: String? = null,
|
||||||
|
val replyToId: Int? = null,
|
||||||
|
val transportFiles: List<SendDmFile> = emptyList(),
|
||||||
|
val uploadedFileIds: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DmAttachmentOutboxPayload(
|
||||||
|
val recipientId: Int,
|
||||||
|
val plaintext: String,
|
||||||
|
val clientMessageId: String,
|
||||||
|
val replyToId: Int? = null,
|
||||||
|
val fileUri: String,
|
||||||
|
val filename: String,
|
||||||
|
val fileSizeBytes: Long = 0L,
|
||||||
|
val aspectRatio: Float? = null,
|
||||||
|
/** Encrypted blob size; used for upload progress after encryption. */
|
||||||
|
val encryptedFileSizeBytes: Long = 0L,
|
||||||
|
val uploadId: String = "",
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.fromchat.api.outbox
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drains pending outbox rows for the active instance (Android: WorkManager; iOS: BG task hook).
|
||||||
|
*/
|
||||||
|
expect fun scheduleOutboxProcessing(instanceId: String)
|
||||||
|
|
||||||
|
expect fun cancelOutboxProcessing(instanceId: String)
|
||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
package ru.fromchat.api.outbox
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.fromchat.api.AttachmentUploadNotifier
|
||||||
|
import ru.fromchat.api.AttachmentUploadProgress
|
||||||
|
import ru.fromchat.ui.chat.AttachmentMediaLog
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.api.db.MessageCacheStore
|
||||||
|
import ru.fromchat.api.db.MessageDatabaseProvider
|
||||||
|
import ru.fromchat.api.db.MessageRepository
|
||||||
|
import ru.fromchat.api.db.conversationIdForDm
|
||||||
|
import ru.fromchat.api.db.conversationIdForGroup
|
||||||
|
import ru.fromchat.api.db.GENERAL_PUBLIC_GROUP_ID
|
||||||
|
import ru.fromchat.core.cache.CacheContext
|
||||||
|
import ru.fromchat.core.cache.clearUploadArtifacts
|
||||||
|
import ru.fromchat.core.cache.clearUploadSecretsOnly
|
||||||
|
import ru.fromchat.core.cache.readOutboundFileBytes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single entry point for enqueueing outbound messages (DB row + outbox + worker).
|
||||||
|
* Panels must not call [ApiClient.sendMessage] / [ApiClient.sendDm] directly.
|
||||||
|
*/
|
||||||
|
object OutgoingMessageCoordinator {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
private val drainMutex = Mutex()
|
||||||
|
private val drainScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
private fun kickOutboxDrain(instanceId: String) {
|
||||||
|
val id = instanceId.trim()
|
||||||
|
if (id.isEmpty()) return
|
||||||
|
scheduleOutboxProcessing(id)
|
||||||
|
drainScope.launch { drainOutboxForInstance(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun enqueuePublicMessage(
|
||||||
|
content: String,
|
||||||
|
replyToId: Int?,
|
||||||
|
clientMessageId: String,
|
||||||
|
optimisticMessage: Message,
|
||||||
|
) {
|
||||||
|
val instanceId = CacheContext.requireActiveInstanceId()
|
||||||
|
val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageRepository.upsertPublicMessage(optimisticMessage)
|
||||||
|
val payload = json.encodeToString(PublicOutboxPayload(content, replyToId))
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
|
||||||
|
instanceId = instanceId,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
conversationId = conversationId,
|
||||||
|
kind = KIND_SEND_PUBLIC,
|
||||||
|
payloadJson = payload,
|
||||||
|
retryCount = 0L,
|
||||||
|
nextAttemptAt = null,
|
||||||
|
bytesUploaded = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
kickOutboxDrain(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun enqueueDmMessage(
|
||||||
|
recipientId: Int,
|
||||||
|
plaintext: String,
|
||||||
|
clientMessageId: String,
|
||||||
|
replyToId: Int?,
|
||||||
|
optimisticMessage: Message,
|
||||||
|
transportFiles: List<ru.fromchat.api.SendDmFile> = emptyList(),
|
||||||
|
uploadedFileIds: List<String> = emptyList(),
|
||||||
|
) {
|
||||||
|
val instanceId = CacheContext.requireActiveInstanceId()
|
||||||
|
val conversationId = conversationIdForDm(recipientId)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
|
||||||
|
val payload = json.encodeToString(
|
||||||
|
DmOutboxPayload(
|
||||||
|
recipientId = recipientId,
|
||||||
|
plaintext = plaintext,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
replyToId = replyToId,
|
||||||
|
transportFiles = transportFiles,
|
||||||
|
uploadedFileIds = uploadedFileIds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
|
||||||
|
instanceId = instanceId,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
conversationId = conversationId,
|
||||||
|
kind = KIND_SEND_DM,
|
||||||
|
payloadJson = payload,
|
||||||
|
retryCount = 0L,
|
||||||
|
nextAttemptAt = null,
|
||||||
|
bytesUploaded = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
kickOutboxDrain(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun enqueueDmAttachment(
|
||||||
|
recipientId: Int,
|
||||||
|
plaintext: String,
|
||||||
|
clientMessageId: String,
|
||||||
|
replyToId: Int?,
|
||||||
|
fileUri: String,
|
||||||
|
filename: String,
|
||||||
|
optimisticMessage: Message,
|
||||||
|
aspectRatio: Float? = null,
|
||||||
|
) {
|
||||||
|
val instanceId = CacheContext.requireActiveInstanceId()
|
||||||
|
val conversationId = conversationIdForDm(recipientId)
|
||||||
|
AttachmentUploadNotifier.emit(
|
||||||
|
AttachmentUploadProgress.Pending(clientMessageId, filename),
|
||||||
|
messageLabel = plaintext,
|
||||||
|
)
|
||||||
|
AttachmentUploadNotifier.emit(
|
||||||
|
AttachmentUploadProgress.InProgress(clientMessageId, 1, filename),
|
||||||
|
messageLabel = plaintext,
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
|
||||||
|
val payload = json.encodeToString(
|
||||||
|
DmAttachmentOutboxPayload(
|
||||||
|
recipientId = recipientId,
|
||||||
|
plaintext = plaintext,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
replyToId = replyToId,
|
||||||
|
fileUri = fileUri,
|
||||||
|
filename = filename,
|
||||||
|
fileSizeBytes = 0L,
|
||||||
|
aspectRatio = aspectRatio?.takeIf { it > 0f },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
|
||||||
|
instanceId = instanceId,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
conversationId = conversationId,
|
||||||
|
kind = KIND_SEND_DM_ATTACHMENT,
|
||||||
|
payloadJson = payload,
|
||||||
|
retryCount = 0L,
|
||||||
|
nextAttemptAt = null,
|
||||||
|
bytesUploaded = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
kickOutboxDrain(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearAttachmentOutboxAfterAck(clientMessageId: String) {
|
||||||
|
val cid = clientMessageId.trim()
|
||||||
|
if (cid.isEmpty()) return
|
||||||
|
val instanceId = CacheContext.requireActiveInstanceId()
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem(instanceId, cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort DELETE of an in-progress resumable upload session on the server. */
|
||||||
|
suspend fun abortDmServerUploadIfNeeded(uploadId: String) {
|
||||||
|
val id = uploadId.trim()
|
||||||
|
if (id.isEmpty()) return
|
||||||
|
runCatching { ApiClient.abortDmUpload(id) }
|
||||||
|
.onSuccess {
|
||||||
|
AttachmentMediaLog.upload("server_abort_ok", "uploadId" to id)
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
AttachmentMediaLog.upload(
|
||||||
|
"server_abort_failed",
|
||||||
|
"uploadId" to id,
|
||||||
|
"error" to (error.message ?: "unknown"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops a queued outbound row, local message, and any upload artifacts. */
|
||||||
|
suspend fun cancelOutboundMessage(clientMessageId: String, conversationId: String) {
|
||||||
|
val cid = clientMessageId.trim()
|
||||||
|
if (cid.isEmpty()) return
|
||||||
|
val instanceId = CacheContext.requireActiveInstanceId()
|
||||||
|
AttachmentMediaLog.upload("cancel_requested", "job" to cid, "conv" to conversationId)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val row = MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
|
.selectOutboxItem(instanceId, cid)
|
||||||
|
.executeAsOneOrNull()
|
||||||
|
if (row?.kind == KIND_SEND_DM_ATTACHMENT) {
|
||||||
|
runCatching {
|
||||||
|
val payload = json.decodeFromString<DmAttachmentOutboxPayload>(row.payloadJson)
|
||||||
|
abortDmServerUploadIfNeeded(payload.uploadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries.deleteOutboxItem(instanceId, cid)
|
||||||
|
MessageCacheStore.deleteMessageByClientMessageId(conversationId, cid)
|
||||||
|
clearUploadArtifacts(instanceId, cid)
|
||||||
|
}
|
||||||
|
AttachmentUploadNotifier.emit(
|
||||||
|
AttachmentUploadProgress.Failed(cid, "Cancelled"),
|
||||||
|
)
|
||||||
|
kickOutboxDrain(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drains pending outbox rows for the active instance (shared by workers and iOS). */
|
||||||
|
suspend fun drainActiveInstanceOutbox(): Boolean =
|
||||||
|
drainOutboxForInstance(CacheContext.activeInstanceId.value.trim())
|
||||||
|
|
||||||
|
/** Drains pending outbox rows for [instanceId]. Returns false if any attachment upload failed. */
|
||||||
|
suspend fun drainOutboxForInstance(instanceId: String): Boolean {
|
||||||
|
val id = instanceId.trim()
|
||||||
|
if (id.isEmpty()) return true
|
||||||
|
return drainMutex.withLock {
|
||||||
|
pruneStaleAttachmentOutbox(id)
|
||||||
|
val rows = withContext(Dispatchers.Default) {
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
|
.selectPendingOutboxForInstance(id)
|
||||||
|
.executeAsList()
|
||||||
|
}
|
||||||
|
var allOk = true
|
||||||
|
for (row in rows) {
|
||||||
|
when (row.kind) {
|
||||||
|
KIND_SEND_PUBLIC -> {
|
||||||
|
runCatching {
|
||||||
|
val payload = json.decodeFromString<PublicOutboxPayload>(row.payloadJson)
|
||||||
|
ApiClient.sendMessageViaHttp(payload.content, payload.replyToId)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
|
.deleteOutboxItem(id, row.clientMessageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KIND_SEND_DM -> {
|
||||||
|
runCatching {
|
||||||
|
val payload = json.decodeFromString<DmOutboxPayload>(row.payloadJson)
|
||||||
|
ApiClient.sendDm(
|
||||||
|
recipientId = payload.recipientId,
|
||||||
|
plaintext = payload.plaintext,
|
||||||
|
clientMessageId = payload.clientMessageId,
|
||||||
|
replyToId = payload.replyToId,
|
||||||
|
transportFiles = payload.transportFiles,
|
||||||
|
uploadedFileIds = payload.uploadedFileIds,
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
|
.deleteOutboxItem(id, row.clientMessageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KIND_SEND_DM_ATTACHMENT -> {
|
||||||
|
if (!DmAttachmentOutboxHandler.process(row)) {
|
||||||
|
allOk = false
|
||||||
|
drainScope.launch {
|
||||||
|
delay(3_000)
|
||||||
|
drainOutboxForInstance(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allOk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val KIND_SEND_PUBLIC = "send_public"
|
||||||
|
const val KIND_SEND_DM = "send_dm"
|
||||||
|
const val KIND_SEND_DM_ATTACHMENT = "send_dm_attachment"
|
||||||
|
/** Upload+send finished; row kept until DM ack so UI can still resolve local preview from outbox. */
|
||||||
|
const val KIND_SEND_DM_ATTACHMENT_AWAITING_ACK = "send_dm_attachment_awaiting_ack"
|
||||||
|
|
||||||
|
suspend fun pruneStaleAttachmentOutboxForInstance(instanceId: String) {
|
||||||
|
val id = instanceId.trim()
|
||||||
|
if (id.isEmpty()) return
|
||||||
|
pruneStaleAttachmentOutbox(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pruneStaleAttachmentOutbox(instanceId: String) {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val db = MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
|
val rows = db.selectPendingOutboxForInstance(instanceId).executeAsList()
|
||||||
|
for (row in rows) {
|
||||||
|
when (row.kind) {
|
||||||
|
KIND_SEND_DM_ATTACHMENT,
|
||||||
|
KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> {
|
||||||
|
if (!MessageCacheStore.hasSentMessageWithClientId(
|
||||||
|
row.conversationId,
|
||||||
|
row.clientMessageId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
db.deleteOutboxItem(instanceId, row.clientMessageId)
|
||||||
|
db.deletePendingMessageByClientMessageId(
|
||||||
|
instanceId,
|
||||||
|
row.conversationId,
|
||||||
|
row.clientMessageId,
|
||||||
|
)
|
||||||
|
clearUploadSecretsOnly(instanceId, row.clientMessageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.fromchat.core
|
||||||
|
|
||||||
|
fun ServerConfigData.configKey(): String {
|
||||||
|
val scheme = if (httpsEnabled) "1" else "0"
|
||||||
|
return "${serverIp.trim().lowercase()}|$apiPort|$scheme"
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import ru.fromchat.api.DeviceSessionInfo
|
import ru.fromchat.api.DeviceSessionInfo
|
||||||
@@ -106,7 +107,18 @@ object Settings {
|
|||||||
throw IllegalStateException("Server config not initialized")
|
throw IllegalStateException("Server config not initialized")
|
||||||
|
|
||||||
var serverConfig: ServerConfigData
|
var serverConfig: ServerConfigData
|
||||||
get() = runBlocking {
|
get() = runBlocking { readServerConfig() }
|
||||||
|
set(value) {
|
||||||
|
runIO { writeServerConfig(value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setServerConfig(value: ServerConfigData) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
writeServerConfig(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun readServerConfig(): ServerConfigData {
|
||||||
migrateLegacyServerUrlIfNeeded()
|
migrateLegacyServerUrlIfNeeded()
|
||||||
if (!settings.contains(SERVER_IP_KEY)) {
|
if (!settings.contains(SERVER_IP_KEY)) {
|
||||||
settings.putString(SERVER_IP_KEY, "fromchat.ru")
|
settings.putString(SERVER_IP_KEY, "fromchat.ru")
|
||||||
@@ -130,7 +142,7 @@ object Settings {
|
|||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
ServerConfigData(
|
return ServerConfigData(
|
||||||
serverIp = ip,
|
serverIp = ip,
|
||||||
apiPort = apiPort,
|
apiPort = apiPort,
|
||||||
callsPort = callsPort,
|
callsPort = callsPort,
|
||||||
@@ -138,7 +150,8 @@ object Settings {
|
|||||||
callsEnabled = callsEnabled,
|
callsEnabled = callsEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
set(value) = runIO {
|
|
||||||
|
private suspend fun writeServerConfig(value: ServerConfigData) {
|
||||||
settings.putString(SERVER_IP_KEY, value.serverIp.trim())
|
settings.putString(SERVER_IP_KEY, value.serverIp.trim())
|
||||||
settings.putInt(API_PORT_KEY, value.apiPort.coerceIn(1, 65535))
|
settings.putInt(API_PORT_KEY, value.apiPort.coerceIn(1, 65535))
|
||||||
settings.putInt(CALLS_PORT_KEY, value.callsPort.coerceIn(1, 65535))
|
settings.putInt(CALLS_PORT_KEY, value.callsPort.coerceIn(1, 65535))
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import ru.fromchat.api.ProfileCache
|
||||||
|
import ru.fromchat.api.db.MessageDatabaseProvider
|
||||||
|
import ru.fromchat.ui.chat.PublicChatPanelCache
|
||||||
|
import ru.fromchat.ui.dm.DmPanelCache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active server instance partition for reads/writes.
|
||||||
|
* All repository queries must use [activeInstanceId] (non-empty when session is valid).
|
||||||
|
*/
|
||||||
|
object CacheContext {
|
||||||
|
private val _activeInstanceId = MutableStateFlow("")
|
||||||
|
val activeInstanceId: StateFlow<String> = _activeInstanceId.asStateFlow()
|
||||||
|
|
||||||
|
private val _activeUserId = MutableStateFlow<Int?>(null)
|
||||||
|
val activeUserId: StateFlow<Int?> = _activeUserId.asStateFlow()
|
||||||
|
|
||||||
|
fun setActiveInstance(instanceId: String, userId: Int?) {
|
||||||
|
val trimmed = instanceId.trim()
|
||||||
|
val changed = _activeInstanceId.value != trimmed
|
||||||
|
_activeInstanceId.value = trimmed
|
||||||
|
_activeUserId.value = userId
|
||||||
|
if (changed) {
|
||||||
|
ProfileCache.onActiveInstanceChanged(trimmed)
|
||||||
|
PublicChatPanelCache.onActiveInstanceChanged(trimmed)
|
||||||
|
DmPanelCache.onActiveInstanceChanged(trimmed)
|
||||||
|
if (trimmed.isNotEmpty()) {
|
||||||
|
MessageDatabaseProvider.rebindUnboundPartition(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requireActiveInstanceId(): String {
|
||||||
|
val id = _activeInstanceId.value.trim()
|
||||||
|
require(id.isNotEmpty()) { "No active server instance id" }
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearActive() {
|
||||||
|
_activeInstanceId.value = ""
|
||||||
|
_activeUserId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.api.db.GENERAL_PUBLIC_GROUP_ID
|
||||||
|
import ru.fromchat.api.db.conversationIdForDm
|
||||||
|
import ru.fromchat.api.db.conversationIdForGroup
|
||||||
|
import ru.fromchat.api.db.dmOtherUserIdFromConversationId
|
||||||
|
import ru.fromchat.api.db.groupIdFromConversationId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-partition validation so stale rows from a wrong server switch cannot render as trusted history.
|
||||||
|
*/
|
||||||
|
object CacheValidator {
|
||||||
|
fun filterMessages(
|
||||||
|
conversationId: String,
|
||||||
|
messages: List<Message>,
|
||||||
|
currentUserId: Int?,
|
||||||
|
dmPeerUserId: Int? = dmOtherUserIdFromConversationId(conversationId),
|
||||||
|
): List<Message> = messages.filter { isMessageValid(it, conversationId, currentUserId, dmPeerUserId) }
|
||||||
|
|
||||||
|
fun isMessageValid(
|
||||||
|
message: Message,
|
||||||
|
conversationId: String,
|
||||||
|
currentUserId: Int?,
|
||||||
|
dmPeerUserId: Int? = dmOtherUserIdFromConversationId(conversationId),
|
||||||
|
): Boolean {
|
||||||
|
val groupId = groupIdFromConversationId(conversationId)
|
||||||
|
if (groupId != null) {
|
||||||
|
if (groupId != GENERAL_PUBLIC_GROUP_ID) return false
|
||||||
|
return message.user_id > 0
|
||||||
|
}
|
||||||
|
val peer = dmPeerUserId ?: return false
|
||||||
|
val self = currentUserId ?: return false
|
||||||
|
if (message.user_id != self && message.user_id != peer) return false
|
||||||
|
if (message.id < 0 && message.client_message_id.isNullOrBlank()) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns false when more than half of rows fail validation — caller should purge and refetch.
|
||||||
|
*/
|
||||||
|
fun isConversationCacheCoherent(
|
||||||
|
conversationId: String,
|
||||||
|
messages: List<Message>,
|
||||||
|
currentUserId: Int?,
|
||||||
|
): Boolean {
|
||||||
|
if (messages.isEmpty()) return true
|
||||||
|
val dmPeer = dmOtherUserIdFromConversationId(conversationId)
|
||||||
|
val valid = filterMessages(conversationId, messages, currentUserId, dmPeer)
|
||||||
|
return valid.size * 2 >= messages.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun conversationIdForDmPeer(otherUserId: Int): String = conversationIdForDm(otherUserId)
|
||||||
|
|
||||||
|
fun conversationIdForPublic(): String = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
/** Deletes the `cacheDir/fromchat/` tree (blobs, DB file on disk). Call after [ru.fromchat.api.db.MessageRepository.clearAllCache]. */
|
||||||
|
expect suspend fun wipeFromChatCacheDirectory()
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects OS "clear cache" (generation sentinel missing) and records a new generation after open.
|
||||||
|
*/
|
||||||
|
expect suspend fun ensureFromChatCacheGeneration()
|
||||||
|
|
||||||
|
expect suspend fun writeFromChatCacheGeneration()
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
/** Original attachment URI is no longer readable (revoked permission, deleted file, etc.). */
|
||||||
|
class OutboundFileUnavailableException(
|
||||||
|
message: String,
|
||||||
|
cause: Throwable? = null,
|
||||||
|
) : Exception(message, cause)
|
||||||
|
|
||||||
|
fun Throwable.isOutboundFileUnavailable(): Boolean {
|
||||||
|
var current: Throwable? = this
|
||||||
|
while (current != null) {
|
||||||
|
if (current is OutboundFileUnavailableException) return true
|
||||||
|
current = current.cause
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
data class StagedOutboundFile(
|
||||||
|
val uri: String,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the picked attachment into instance-scoped cache so uploads survive process death.
|
||||||
|
*/
|
||||||
|
expect suspend fun stageOutboundFileForUpload(
|
||||||
|
instanceId: String,
|
||||||
|
clientMessageId: String,
|
||||||
|
sourceUri: String,
|
||||||
|
): StagedOutboundFile
|
||||||
|
|
||||||
|
/** Reads the picked attachment from a platform URI string. */
|
||||||
|
expect suspend fun readOutboundFileBytes(fileUri: String): ByteArray
|
||||||
|
|
||||||
|
expect suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray)
|
||||||
|
|
||||||
|
expect suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray?
|
||||||
|
|
||||||
|
expect suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String)
|
||||||
|
|
||||||
|
expect suspend fun loadUploadTransportCipherJson(instanceId: String, clientMessageId: String): String?
|
||||||
|
|
||||||
|
expect suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String)
|
||||||
|
|
||||||
|
/** Drops upload secrets and staging copy; keeps [DecryptedImageCache] files intact. */
|
||||||
|
expect suspend fun clearUploadSecretsOnly(instanceId: String, clientMessageId: String)
|
||||||
@@ -28,10 +28,10 @@ object Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update server configuration
|
* Update server configuration (persists to storage before updating in-memory state).
|
||||||
*/
|
*/
|
||||||
fun updateServerConfig(config: ServerConfigData) {
|
suspend fun updateServerConfig(config: ServerConfigData) {
|
||||||
Settings.serverConfig = config
|
Settings.setServerConfig(config)
|
||||||
_serverConfig.value = config
|
_serverConfig.value = config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package ru.fromchat.core.instance
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
import ru.fromchat.api.db.InstanceRegistryStore
|
||||||
|
import ru.fromchat.core.ServerConfigData
|
||||||
|
import ru.fromchat.core.cache.CacheContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles [INSTANCE_ID_HEADER] on API responses (main client + probe during server setup).
|
||||||
|
*/
|
||||||
|
object InstanceIdGuard {
|
||||||
|
const val INSTANCE_ID_HEADER = "X-FromChat-Instance-Id"
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var probeConfig: ServerConfigData? = null
|
||||||
|
|
||||||
|
fun onResponseHeader(headerValue: String?, config: ServerConfigData? = null) {
|
||||||
|
val raw = headerValue?.trim().orEmpty()
|
||||||
|
if (raw.isEmpty() || !isValidInstanceUuid(raw)) return
|
||||||
|
val cfg = config ?: probeConfig ?: return
|
||||||
|
scope.launch {
|
||||||
|
val bound = InstanceRegistryStore.getActiveInstanceIdForConfig(cfg)?.trim().orEmpty()
|
||||||
|
when {
|
||||||
|
bound.isEmpty() -> InstanceRegistryStore.rebindServerInstance(cfg, raw)
|
||||||
|
!bound.equals(raw, ignoreCase = true) ->
|
||||||
|
InstanceRegistryStore.rebindServerInstanceOnMismatch(cfg, bound, raw)
|
||||||
|
else -> InstanceRegistryStore.registerInstanceEncountered(raw)
|
||||||
|
}
|
||||||
|
if (probeConfig == null && CacheContext.activeInstanceId.value.isNotEmpty()) {
|
||||||
|
val userId = CacheContext.activeUserId.value
|
||||||
|
if (!bound.equals(raw, ignoreCase = true)) {
|
||||||
|
CacheContext.setActiveInstance(raw, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package ru.fromchat.core.instance
|
||||||
|
|
||||||
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
|
import io.ktor.client.plugins.HttpRequestTimeoutException
|
||||||
|
import io.ktor.client.network.sockets.ConnectTimeoutException
|
||||||
|
import io.ktor.client.network.sockets.SocketTimeoutException
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.db.InstanceRegistryStore
|
||||||
|
import ru.fromchat.core.ServerConfigData
|
||||||
|
import ru.fromchat.core.configKey
|
||||||
|
|
||||||
|
private val UUID_REGEX =
|
||||||
|
Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||||
|
|
||||||
|
fun isValidInstanceUuid(value: String): Boolean = UUID_REGEX.matches(value.trim())
|
||||||
|
|
||||||
|
sealed interface InstanceIdResolveResult {
|
||||||
|
data class Cached(val instanceId: String) : InstanceIdResolveResult
|
||||||
|
data class Fetched(val instanceId: String) : InstanceIdResolveResult
|
||||||
|
data class InstanceIdChanged(val oldId: String, val newId: String) : InstanceIdResolveResult
|
||||||
|
data object Unsupported : InstanceIdResolveResult
|
||||||
|
data object Timeout : InstanceIdResolveResult
|
||||||
|
data object Unreachable : InstanceIdResolveResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val INSTANCE_FETCH_MS = 10_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves instance id for [config]: reads DB binding, optionally fetches `/instance_id`.
|
||||||
|
* On mismatch with cached binding, rebinds via [InstanceRegistryStore] (no partition delete).
|
||||||
|
*/
|
||||||
|
suspend fun resolveInstanceId(
|
||||||
|
config: ServerConfigData,
|
||||||
|
apiBaseUrl: String,
|
||||||
|
forceNetwork: Boolean,
|
||||||
|
): InstanceIdResolveResult {
|
||||||
|
val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty()
|
||||||
|
if (!forceNetwork && cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||||
|
return InstanceIdResolveResult.Cached(cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
val fetchResult = runCatching {
|
||||||
|
withTimeout(INSTANCE_FETCH_MS) {
|
||||||
|
ApiClient.fetchServerInstanceId(apiBaseUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fetchResult.isFailure) {
|
||||||
|
return networkFailureToResolveResult(fetchResult.exceptionOrNull(), cached)
|
||||||
|
}
|
||||||
|
val fetched = fetchResult.getOrThrow().trim()
|
||||||
|
|
||||||
|
if (fetched.isEmpty() || !isValidInstanceUuid(fetched)) {
|
||||||
|
return InstanceIdResolveResult.Unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached.isEmpty()) {
|
||||||
|
InstanceRegistryStore.rebindServerInstance(config, fetched)
|
||||||
|
return InstanceIdResolveResult.Fetched(fetched)
|
||||||
|
}
|
||||||
|
if (cached.equals(fetched, ignoreCase = true)) {
|
||||||
|
InstanceRegistryStore.registerInstanceEncountered(fetched)
|
||||||
|
return InstanceIdResolveResult.Fetched(fetched)
|
||||||
|
}
|
||||||
|
InstanceRegistryStore.rebindServerInstanceOnMismatch(config, cached, fetched)
|
||||||
|
return InstanceIdResolveResult.InstanceIdChanged(cached, fetched)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun apiBaseUrlFor(config: ServerConfigData): String {
|
||||||
|
val scheme = if (config.httpsEnabled) "https" else "http"
|
||||||
|
return "$scheme://${config.serverIp}:${config.apiPort}/api"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun networkFailureToResolveResult(
|
||||||
|
e: Throwable?,
|
||||||
|
cached: String,
|
||||||
|
): InstanceIdResolveResult = when (e) {
|
||||||
|
is TimeoutCancellationException,
|
||||||
|
is HttpRequestTimeoutException,
|
||||||
|
is SocketTimeoutException,
|
||||||
|
-> {
|
||||||
|
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||||
|
InstanceIdResolveResult.Cached(cached)
|
||||||
|
} else {
|
||||||
|
InstanceIdResolveResult.Timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ConnectTimeoutException -> {
|
||||||
|
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||||
|
InstanceIdResolveResult.Cached(cached)
|
||||||
|
} else {
|
||||||
|
InstanceIdResolveResult.Unreachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ClientRequestException -> {
|
||||||
|
if (e.response.status.value in 400..499) {
|
||||||
|
InstanceIdResolveResult.Unsupported
|
||||||
|
} else if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||||
|
InstanceIdResolveResult.Cached(cached)
|
||||||
|
} else {
|
||||||
|
InstanceIdResolveResult.Unreachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||||
|
InstanceIdResolveResult.Cached(cached)
|
||||||
|
} else {
|
||||||
|
InstanceIdResolveResult.Unreachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package ru.fromchat.core.instance
|
||||||
|
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.WebSocketManager
|
||||||
|
import ru.fromchat.api.db.InstanceRegistryStore
|
||||||
|
import ru.fromchat.core.ServerConfigData
|
||||||
|
import ru.fromchat.core.cache.CacheContext
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
|
||||||
|
sealed interface ServerProbeResult {
|
||||||
|
data class Supported(
|
||||||
|
val instanceId: String,
|
||||||
|
val callsOk: Boolean,
|
||||||
|
val pingMs: Int,
|
||||||
|
) : ServerProbeResult
|
||||||
|
|
||||||
|
data object Unsupported : ServerProbeResult
|
||||||
|
data object Timeout : ServerProbeResult
|
||||||
|
data object Unreachable : ServerProbeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CALLS_PROBE_MS = 1_500L
|
||||||
|
|
||||||
|
suspend fun probeCallsReachable(config: ServerConfigData): Boolean {
|
||||||
|
val urlScheme = if (config.httpsEnabled) "https" else "http"
|
||||||
|
val host = config.serverIp.trim()
|
||||||
|
val authorityHost = host.removePrefix("[").removeSuffix("]").ifEmpty { host }
|
||||||
|
val root = "$urlScheme://$authorityHost:${config.callsPort}/"
|
||||||
|
return runCatching {
|
||||||
|
withTimeout(CALLS_PROBE_MS) {
|
||||||
|
ApiClient.probeHttpGet(root)
|
||||||
|
}
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun probeServer(config: ServerConfigData): ServerProbeResult {
|
||||||
|
val apiBase = apiBaseUrlFor(config)
|
||||||
|
val mark = TimeSource.Monotonic.markNow()
|
||||||
|
InstanceIdGuard.probeConfig = config
|
||||||
|
try {
|
||||||
|
val resolve = resolveInstanceId(config, apiBase, forceNetwork = true)
|
||||||
|
val pingMs = mark.elapsedNow().inWholeMilliseconds.toInt().coerceAtLeast(0)
|
||||||
|
val instanceId = when (resolve) {
|
||||||
|
is InstanceIdResolveResult.Cached -> resolve.instanceId
|
||||||
|
is InstanceIdResolveResult.Fetched -> resolve.instanceId
|
||||||
|
is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
|
||||||
|
InstanceIdResolveResult.Unsupported -> return ServerProbeResult.Unsupported
|
||||||
|
InstanceIdResolveResult.Timeout -> return ServerProbeResult.Timeout
|
||||||
|
InstanceIdResolveResult.Unreachable -> return ServerProbeResult.Unreachable
|
||||||
|
}
|
||||||
|
val callsOk = probeCallsReachable(config)
|
||||||
|
return ServerProbeResult.Supported(instanceId, callsOk, pingMs)
|
||||||
|
} finally {
|
||||||
|
InstanceIdGuard.probeConfig = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun applyServerConfig(
|
||||||
|
config: ServerConfigData,
|
||||||
|
instanceId: String,
|
||||||
|
callsOk: Boolean,
|
||||||
|
) {
|
||||||
|
val tentative = config.copy(callsEnabled = callsOk)
|
||||||
|
Config.updateServerConfig(tentative)
|
||||||
|
val userId = ApiClient.user?.id
|
||||||
|
InstanceRegistryStore.rebindServerInstance(tentative, instanceId)
|
||||||
|
CacheContext.setActiveInstance(instanceId, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun applyServerAndNavigate(
|
||||||
|
probe: ServerProbeResult.Supported,
|
||||||
|
config: ServerConfigData,
|
||||||
|
bearer: String,
|
||||||
|
onNavigateLogin: suspend () -> Unit,
|
||||||
|
onNavigateChat: suspend () -> Unit,
|
||||||
|
onLogoutOldHost: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
val apiBase = apiBaseUrlFor(config)
|
||||||
|
val token = bearer.trim()
|
||||||
|
if (token.isEmpty()) {
|
||||||
|
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
||||||
|
WebSocketManager.disconnect()
|
||||||
|
onNavigateLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val authOk = ApiClient.checkAuthAt(apiBase, token)
|
||||||
|
if (!authOk) {
|
||||||
|
onLogoutOldHost()
|
||||||
|
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
||||||
|
WebSocketManager.disconnect()
|
||||||
|
onNavigateLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
||||||
|
WebSocketManager.disconnect()
|
||||||
|
WebSocketManager.connect(forceRestart = true)
|
||||||
|
onNavigateChat()
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package ru.fromchat.core.instance
|
||||||
|
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.db.InstanceRegistryStore
|
||||||
|
import ru.fromchat.core.Settings
|
||||||
|
import ru.fromchat.api.outbox.scheduleOutboxProcessing
|
||||||
|
import ru.fromchat.core.cache.CacheContext
|
||||||
|
import ru.fromchat.core.config.Config
|
||||||
|
|
||||||
|
sealed interface SessionBootstrapResult {
|
||||||
|
data object Ready : SessionBootstrapResult
|
||||||
|
data object OfflineCached : SessionBootstrapResult
|
||||||
|
data object LogoutRequired : SessionBootstrapResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures [CacheContext] has an active instance for the current server config when a session exists.
|
||||||
|
*/
|
||||||
|
suspend fun bootstrapSessionInstance(hasToken: Boolean): SessionBootstrapResult {
|
||||||
|
if (!hasToken) return SessionBootstrapResult.Ready
|
||||||
|
val config = Settings.serverConfig
|
||||||
|
val apiBase = apiBaseUrlFor(config)
|
||||||
|
val resolve = resolveInstanceId(
|
||||||
|
config = config,
|
||||||
|
apiBaseUrl = apiBase,
|
||||||
|
forceNetwork = true,
|
||||||
|
)
|
||||||
|
return when (resolve) {
|
||||||
|
is InstanceIdResolveResult.Cached,
|
||||||
|
is InstanceIdResolveResult.Fetched,
|
||||||
|
is InstanceIdResolveResult.InstanceIdChanged,
|
||||||
|
-> {
|
||||||
|
val id = when (resolve) {
|
||||||
|
is InstanceIdResolveResult.Cached -> resolve.instanceId
|
||||||
|
is InstanceIdResolveResult.Fetched -> resolve.instanceId
|
||||||
|
is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
|
||||||
|
}
|
||||||
|
CacheContext.setActiveInstance(id, ApiClient.user?.id)
|
||||||
|
scheduleOutboxProcessing(id)
|
||||||
|
SessionBootstrapResult.Ready
|
||||||
|
}
|
||||||
|
InstanceIdResolveResult.Timeout,
|
||||||
|
InstanceIdResolveResult.Unreachable,
|
||||||
|
-> {
|
||||||
|
val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty()
|
||||||
|
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||||
|
CacheContext.setActiveInstance(cached, ApiClient.user?.id)
|
||||||
|
scheduleOutboxProcessing(cached)
|
||||||
|
SessionBootstrapResult.OfflineCached
|
||||||
|
} else {
|
||||||
|
SessionBootstrapResult.OfflineCached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InstanceIdResolveResult.Unsupported -> SessionBootstrapResult.LogoutRequired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logoutIfInstanceUnsupported() {
|
||||||
|
runCatching { ApiClient.logout() }
|
||||||
|
ApiClient.clearMemorySession()
|
||||||
|
CacheContext.clearActive()
|
||||||
|
}
|
||||||
@@ -57,7 +57,9 @@ suspend fun decryptEnvelope(envelope: DmEnvelope, currentUserId: Int?): String {
|
|||||||
suspend fun decryptFile(
|
suspend fun decryptFile(
|
||||||
file: ru.fromchat.api.DmFile,
|
file: ru.fromchat.api.DmFile,
|
||||||
envelope: DmEnvelope,
|
envelope: DmEnvelope,
|
||||||
currentUserId: Int?
|
currentUserId: Int?,
|
||||||
|
downloadResumeKey: String? = null,
|
||||||
|
onDownloadProgress: ((Int) -> Unit)? = null,
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
val wrappedMekB64 = file.wrappedMekB64
|
val wrappedMekB64 = file.wrappedMekB64
|
||||||
?: envelope.files?.find { it.path == file.path }?.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}")
|
?: throw IllegalArgumentException("No nonce available for file decryption: ${file.path}")
|
||||||
|
|
||||||
val mek = unwrapMek(wrappedMekB64, envelope, currentUserId)
|
val mek = unwrapMek(wrappedMekB64, envelope, currentUserId)
|
||||||
val encryptedBytes = ru.fromchat.api.ApiClient.fetchEncryptedFile(file.path)
|
ru.fromchat.core.Logger.d("DmCrypto", "fetchEncryptedFile path=${file.path}")
|
||||||
val ciphertextB64 = com.pr0gramm3r101.utils.crypto.Base64.encode(encryptedBytes)
|
val encryptedBytes = if (downloadResumeKey != null) {
|
||||||
return DmCrypto.decryptEnvelope(nonceB64, ciphertextB64, mek)
|
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
|
* @return Decrypted plaintext
|
||||||
*/
|
*/
|
||||||
suspend fun decryptEnvelope(ivB64: String, ciphertextB64: String, mek: ByteArray): ByteArray
|
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.WebSocketManager
|
||||||
import ru.fromchat.api.WebSocketMessage
|
import ru.fromchat.api.WebSocketMessage
|
||||||
import ru.fromchat.api.WebSocketUpdatesData
|
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.Logger
|
||||||
|
import ru.fromchat.core.cache.ensureFromChatCacheGeneration
|
||||||
import ru.fromchat.core.config.Config
|
import ru.fromchat.core.config.Config
|
||||||
import ru.fromchat.net.NetworkConnectivity
|
import ru.fromchat.net.NetworkConnectivity
|
||||||
import ru.fromchat.ui.auth.LoginScreen
|
import ru.fromchat.ui.auth.LoginScreen
|
||||||
@@ -191,11 +195,27 @@ fun App(
|
|||||||
Config.initialize()
|
Config.initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runCatching { ensureFromChatCacheGeneration() }
|
||||||
|
|
||||||
runCatching { NetworkConnectivity.ensureStarted() }
|
runCatching { NetworkConnectivity.ensureStarted() }
|
||||||
|
|
||||||
// Load persisted token and user data
|
// Load persisted token and user data
|
||||||
ApiClient.loadPersistedData()
|
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() }
|
runCatching { ProfileCache.hydrateFromDisk() }
|
||||||
|
|
||||||
val hasTokenInitially = ApiClient.token?.isNotEmpty() == true
|
val hasTokenInitially = ApiClient.token?.isNotEmpty() == true
|
||||||
@@ -235,6 +255,15 @@ fun App(
|
|||||||
Lifecycle.Event.ON_START -> {
|
Lifecycle.Event.ON_START -> {
|
||||||
AppForeground.setForeground(true)
|
AppForeground.setForeground(true)
|
||||||
WebSocketManager.connect()
|
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)
|
Lifecycle.Event.ON_STOP -> AppForeground.setForeground(false)
|
||||||
else -> {}
|
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. */
|
/** Get image aspect ratio (width/height) from URI without loading full image. Returns null if unavailable. */
|
||||||
expect suspend fun getImageAspectRatio(uri: String): Float?
|
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.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
@@ -64,11 +76,18 @@ import com.pr0gramm3r101.utils.crypto.Base64
|
|||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
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.DmEnvelope
|
||||||
import ru.fromchat.api.DmFile
|
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_SIZE = 160.dp
|
||||||
private val IMAGE_RADIUS = 12.dp
|
private const val BLUR_FADE_MS = 450
|
||||||
|
|
||||||
internal fun isImageFilename(name: String): Boolean =
|
internal fun isImageFilename(name: String): Boolean =
|
||||||
name.endsWith(".png", true) || name.endsWith(".jpg", true) ||
|
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. */
|
/** Filename for pending (non-image) files; used when pendingFileUri is set. */
|
||||||
pendingFilename: String? = null,
|
pendingFilename: String? = null,
|
||||||
isUploading: Boolean,
|
isUploading: Boolean,
|
||||||
|
/** Waiting for server ack after upload finished (keep blur, no progress ring). */
|
||||||
|
awaitingServerAck: Boolean = false,
|
||||||
/** 0–100 upload progress when isUploading; null = indefinite */
|
/** 0–100 upload progress when isUploading; null = indefinite */
|
||||||
uploadProgress: Int? = null,
|
uploadProgress: Int? = null,
|
||||||
fileThumbnail: String? = null,
|
fileThumbnail: String? = null,
|
||||||
@@ -91,11 +112,14 @@ fun AttachmentPreview(
|
|||||||
fileSizeBytes: Long? = null,
|
fileSizeBytes: Long? = null,
|
||||||
messageId: Int? = null,
|
messageId: Int? = null,
|
||||||
fileIndex: Int? = null,
|
fileIndex: Int? = null,
|
||||||
|
clientMessageId: String? = null,
|
||||||
onFileClick: (() -> Unit)? = null,
|
onFileClick: (() -> Unit)? = null,
|
||||||
onImageClick: (() -> Unit)? = null,
|
onImageClick: (() -> Unit)? = null,
|
||||||
onImageBounds: ((Rect) -> Unit)? = null,
|
onImageBounds: ((Rect) -> Unit)? = null,
|
||||||
isExpanded: Boolean = false,
|
isExpanded: Boolean = false,
|
||||||
isAuthor: Boolean = false,
|
isAuthor: Boolean = false,
|
||||||
|
/** Message text shown in attachment download/upload logs. */
|
||||||
|
messageLabel: String? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val isImage = when {
|
val isImage = when {
|
||||||
@@ -108,8 +132,9 @@ fun AttachmentPreview(
|
|||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
val isImageWithThumb = file != null && isImage && dmEnvelope != null && !fileThumbnail.isNullOrBlank()
|
val isPendingImage = pendingFileUri != null && isImage && file == null && dmEnvelope == null
|
||||||
val isPendingImage = pendingFileUri != null && isImage
|
val isConfirmedImage = file != null && isImage && dmEnvelope != null && !isPendingImage
|
||||||
|
val showImageTile = isPendingImage || isConfirmedImage
|
||||||
val isPendingFile = pendingFileUri != null && !isImage
|
val isPendingFile = pendingFileUri != null && !isImage
|
||||||
|
|
||||||
when {
|
when {
|
||||||
@@ -124,20 +149,29 @@ fun AttachmentPreview(
|
|||||||
sizeBytes = fileSizeBytes,
|
sizeBytes = fileSizeBytes,
|
||||||
onClick = if (file != null) onFileClick else null,
|
onClick = if (file != null) onFileClick else null,
|
||||||
isAuthor = isAuthor,
|
isAuthor = isAuthor,
|
||||||
isUploading = isPendingFile && isUploading,
|
isUploading = isPendingFile && (isUploading || awaitingServerAck),
|
||||||
uploadProgress = if (isPendingFile) uploadProgress else null,
|
uploadProgress = if (isPendingFile) uploadProgress else null,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
isImageWithThumb || isPendingImage -> {
|
showImageTile -> {
|
||||||
var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) {
|
var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.then(
|
.then(
|
||||||
if (onImageClick != null && isFullyLoaded && !isExpanded && (isImageWithThumb || !isPendingImage)) Modifier.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onImageClick)
|
if (onImageClick != null && !isExpanded &&
|
||||||
else Modifier
|
(isPendingImage || isFullyLoaded || pendingFileUri != null)
|
||||||
|
) {
|
||||||
|
Modifier.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
onClick = onImageClick,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.conditional(
|
.conditional(
|
||||||
fileAspectRatio != null && fileAspectRatio > 0f,
|
fileAspectRatio != null && fileAspectRatio > 0f,
|
||||||
@@ -150,9 +184,9 @@ fun AttachmentPreview(
|
|||||||
Modifier.size(IMAGE_SIZE)
|
Modifier.size(IMAGE_SIZE)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.clip(RoundedCornerShape(IMAGE_RADIUS))
|
.clip(attachmentImageCornerShape(isAuthor))
|
||||||
.then(
|
.then(
|
||||||
if (onImageBounds != null && (isImageWithThumb || !isPendingImage)) {
|
if (onImageBounds != null && showImageTile) {
|
||||||
Modifier.onGloballyPositioned { coords ->
|
Modifier.onGloballyPositioned { coords ->
|
||||||
val pos = coords.positionInRoot()
|
val pos = coords.positionInRoot()
|
||||||
val size = coords.size
|
val size = coords.size
|
||||||
@@ -176,27 +210,26 @@ fun AttachmentPreview(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.graphicsLayer { alpha = if (isExpanded) 0f else 1f }
|
.graphicsLayer { alpha = if (isExpanded) 0f else 1f }
|
||||||
) {
|
) {
|
||||||
when {
|
val imageStableKey = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
isPendingImage -> UnifiedImageContent(
|
?: "img:${messageId ?: 0}:${fileIndex ?: 0}"
|
||||||
localUri = pendingFileUri,
|
key(imageStableKey) {
|
||||||
messageId = messageId,
|
ChatImageTileContent(
|
||||||
fileIndex = fileIndex,
|
|
||||||
file = file,
|
|
||||||
envelope = dmEnvelope,
|
|
||||||
currentUserId = currentUserId,
|
|
||||||
isUploading = isUploading,
|
|
||||||
uploadProgress = uploadProgress,
|
|
||||||
onFullyLoaded = { if (it) isFullyLoaded = true }
|
|
||||||
)
|
|
||||||
else -> DecryptedImageContent(
|
|
||||||
messageId = messageId ?: -1,
|
messageId = messageId ?: -1,
|
||||||
fileIndex = fileIndex ?: 0,
|
fileIndex = fileIndex ?: 0,
|
||||||
file = file!!,
|
clientMessageId = clientMessageId,
|
||||||
envelope = dmEnvelope!!,
|
localUri = pendingFileUri,
|
||||||
currentUserId = currentUserId,
|
serverFile = file,
|
||||||
thumbnailBase64 = fileThumbnail!!,
|
envelope = dmEnvelope,
|
||||||
|
thumbnailBase64 = fileThumbnail,
|
||||||
aspectRatio = fileAspectRatio,
|
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)
|
@OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun UnifiedImageContent(
|
private fun ChatImageTileContent(
|
||||||
localUri: String,
|
messageId: Int,
|
||||||
messageId: Int?,
|
fileIndex: Int,
|
||||||
fileIndex: Int?,
|
clientMessageId: String?,
|
||||||
file: DmFile?,
|
localUri: String?,
|
||||||
|
serverFile: DmFile?,
|
||||||
envelope: DmEnvelope?,
|
envelope: DmEnvelope?,
|
||||||
|
thumbnailBase64: String?,
|
||||||
|
aspectRatio: Float?,
|
||||||
currentUserId: Int?,
|
currentUserId: Int?,
|
||||||
|
isAuthor: Boolean,
|
||||||
|
isOutboundPending: Boolean,
|
||||||
isUploading: Boolean,
|
isUploading: Boolean,
|
||||||
|
awaitingServerAck: Boolean,
|
||||||
uploadProgress: Int?,
|
uploadProgress: Int?,
|
||||||
onFullyLoaded: (Boolean) -> Unit = {}
|
messageLabel: String? = null,
|
||||||
|
onFullyLoaded: (Boolean) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var cachedPath by remember(messageId, fileIndex, file?.path) {
|
val clipShape = attachmentImageCornerShape(isAuthor)
|
||||||
mutableStateOf(
|
val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
if (messageId != null && fileIndex != null && file != null) {
|
val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f }
|
||||||
DecryptedImageCache.getCached(messageId, fileIndex, file.path)
|
val fallbackDecodeSize = rememberChatPreviewDecodeSize(IMAGE_SIZE, layoutAspect)
|
||||||
} else {
|
val seedDecodeSize = remember(layoutAspect) { previewSeedDecodeSize(layoutAspect) }
|
||||||
null
|
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) {
|
val initialFull = remember(decryptCacheKey) { LocalDecodedImageCache.peekFull(decryptCacheKey) }
|
||||||
cachedPath = if (messageId != null && fileIndex != null && file != null && envelope != null) {
|
val hadInstantFull = initialFull != null
|
||||||
DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId)
|
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 {
|
} 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(
|
var cachedPath by remember(decryptCacheKey) {
|
||||||
model = localUri,
|
mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId))
|
||||||
contentScale = ContentScale.Crop
|
}
|
||||||
|
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 localState by localPainter.state.collectAsState()
|
val diskUri = DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)
|
||||||
LaunchedEffect(localState) {
|
val localPaths = buildList {
|
||||||
if (localState is coil3.compose.AsyncImagePainter.State.Success) {
|
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)
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.clip(RoundedCornerShape(IMAGE_RADIUS))
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clip(clipShape),
|
||||||
) {
|
) {
|
||||||
Image(
|
when {
|
||||||
painter = localPainter,
|
else -> {
|
||||||
contentDescription = null,
|
Box(
|
||||||
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))
|
|
||||||
}
|
|
||||||
Image(
|
|
||||||
painter = fullPainter,
|
|
||||||
contentDescription = file.name,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.alpha(alpha.value),
|
.background(tilePlaceholderColor),
|
||||||
contentScale = ContentScale.FillWidth
|
)
|
||||||
|
if (fullBitmap == null && hasLocalSource && !showOutboundBlurOverlay) {
|
||||||
|
AsyncImage(
|
||||||
|
model = localUri,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = imageContentScale,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> Unit
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(
|
private fun UploadingImageOverlay(
|
||||||
model: String,
|
model: String,
|
||||||
uploadProgress: Int?,
|
uploadProgress: Int?,
|
||||||
modifier: Modifier = Modifier
|
clipShape: RoundedCornerShape,
|
||||||
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.matchParentSize()
|
.matchParentSize()
|
||||||
.clip(RoundedCornerShape(IMAGE_RADIUS))
|
.clip(clipShape)
|
||||||
.hazeEffect(style = HazeMaterials.thin())
|
.hazeEffect(style = HazeMaterials.thin())
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = model,
|
model = model,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = contentScale,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
@@ -349,11 +718,16 @@ private fun ExpressiveUploadIndicator(
|
|||||||
uploadProgress: Int?,
|
uploadProgress: Int?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
indicatorColor: Color? = null,
|
indicatorColor: Color? = null,
|
||||||
trackColorOverride: Color? = null
|
trackColorOverride: Color? = null,
|
||||||
) {
|
) {
|
||||||
val clampedProgress = uploadProgress?.coerceIn(0, 100)
|
// Latch first percent so we do not swap indeterminate ↔ determinate indicators (that resets animation).
|
||||||
val indeterminate = clampedProgress == null || clampedProgress == 0
|
var latchedPercent by remember { mutableStateOf<Int?>(null) }
|
||||||
val waveActive = clampedProgress != null && clampedProgress in 1..99
|
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)
|
val waveAnimSpec = tween<Float>(durationMillis = 320, easing = FastOutSlowInEasing)
|
||||||
|
|
||||||
@@ -406,7 +780,7 @@ private fun PendingImageContent(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.clip(RoundedCornerShape(IMAGE_RADIUS))
|
.clip(attachmentImageCornerShape(isAuthor = false))
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = uri,
|
model = uri,
|
||||||
@@ -430,7 +804,8 @@ private fun PendingImageContent(
|
|||||||
UploadingImageOverlay(
|
UploadingImageOverlay(
|
||||||
model = uri,
|
model = uri,
|
||||||
uploadProgress = uploadProgress,
|
uploadProgress = uploadProgress,
|
||||||
modifier = Modifier.matchParentSize()
|
clipShape = attachmentImageCornerShape(isAuthor = false),
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(modifier = Modifier.matchParentSize())
|
Box(modifier = Modifier.matchParentSize())
|
||||||
@@ -491,154 +866,89 @@ private fun DeterminateCircularProgress(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DecryptedImageContent(
|
internal fun CachedAttachmentImage(
|
||||||
messageId: Int,
|
bitmap: ImageBitmap,
|
||||||
fileIndex: Int,
|
contentDescription: String?,
|
||||||
file: DmFile,
|
contentScale: ContentScale,
|
||||||
envelope: DmEnvelope,
|
modifier: Modifier = Modifier.fillMaxSize(),
|
||||||
currentUserId: Int?,
|
|
||||||
thumbnailBase64: String,
|
|
||||||
aspectRatio: Float?,
|
|
||||||
onFullyLoaded: (Boolean) -> Unit = {}
|
|
||||||
) {
|
) {
|
||||||
var cachedPath by remember(messageId, fileIndex, file.path) {
|
Image(
|
||||||
mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, file.path))
|
bitmap = bitmap,
|
||||||
}
|
contentDescription = contentDescription,
|
||||||
val thumbnailBytes = remember(thumbnailBase64) {
|
modifier = modifier,
|
||||||
runCatching { Base64.decode(thumbnailBase64) }.getOrNull()
|
contentScale = contentScale,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(messageId, fileIndex, file.path, envelope) {
|
@Composable
|
||||||
cachedPath = DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId)
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
@Composable
|
||||||
modifier = Modifier
|
private fun AttachmentImageLoadFailedOverlay(
|
||||||
.fillMaxSize()
|
isAuthor: Boolean,
|
||||||
.clip(RoundedCornerShape(IMAGE_RADIUS))
|
onRetry: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
val failedText = stringResource(Res.string.attachment_image_load_failed)
|
||||||
when {
|
val retryText = stringResource(Res.string.attachment_retry)
|
||||||
cachedPath != null -> {
|
val retryCd = stringResource(Res.string.cd_attachment_retry)
|
||||||
val fullPainter = rememberAsyncImagePainter(
|
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
|
||||||
model = cachedPath!!,
|
Box(
|
||||||
contentScale = ContentScale.FillWidth
|
modifier = modifier
|
||||||
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = failedText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = headlineColor,
|
||||||
)
|
)
|
||||||
val fullState by fullPainter.state.collectAsState()
|
TextButton(
|
||||||
when (fullState) {
|
onClick = onRetry,
|
||||||
is coil3.compose.AsyncImagePainter.State.Success -> {
|
modifier = Modifier.semantics { contentDescription = retryCd },
|
||||||
LaunchedEffect(Unit) { onFullyLoaded(true) }
|
) {
|
||||||
Image(
|
Text(text = retryText, color = headlineColor)
|
||||||
painter = fullPainter,
|
}
|
||||||
contentDescription = file.name,
|
}
|
||||||
modifier = Modifier.fillMaxSize(),
|
}
|
||||||
contentScale = ContentScale.FillWidth
|
}
|
||||||
|
|
||||||
|
@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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatFileSize(bytes: Long): String {
|
private fun formatFileSize(bytes: Long): String {
|
||||||
|
|||||||
@@ -8,9 +8,15 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.Message
|
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.api.WebSocketMessage
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
|
import ru.fromchat.ui.chat.dedupeMessagesByClientId
|
||||||
|
import ru.fromchat.ui.chat.dropSupersededOptimisticMessages
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
@@ -70,6 +76,17 @@ abstract class ChatPanel(
|
|||||||
*/
|
*/
|
||||||
fun getState(): ChatPanelState = _state.copy()
|
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
|
* Update state
|
||||||
*/
|
*/
|
||||||
@@ -103,9 +120,9 @@ abstract class ChatPanel(
|
|||||||
batchDepth--
|
batchDepth--
|
||||||
if (batchDepth == 0) {
|
if (batchDepth == 0) {
|
||||||
val callback = onStateChange
|
val callback = onStateChange
|
||||||
val stateToSend = pendingBatchedState
|
|
||||||
pendingBatchedState = null
|
pendingBatchedState = null
|
||||||
if (callback != null && stateToSend != null) {
|
val stateToSend = _state.copy()
|
||||||
|
if (callback != null) {
|
||||||
scope.launch(Dispatchers.Main) {
|
scope.launch(Dispatchers.Main) {
|
||||||
Logger.d(
|
Logger.d(
|
||||||
"ChatPanel",
|
"ChatPanel",
|
||||||
@@ -142,7 +159,7 @@ abstract class ChatPanel(
|
|||||||
if (!messageExists) {
|
if (!messageExists) {
|
||||||
Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}")
|
Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}")
|
||||||
updateState { currentState ->
|
updateState { currentState ->
|
||||||
val newMessages = currentState.messages + message
|
val newMessages = sortMessagesForChatDisplay(currentState.messages + message)
|
||||||
Logger.d("ChatPanel", "Messages count after add: ${newMessages.size}")
|
Logger.d("ChatPanel", "Messages count after add: ${newMessages.size}")
|
||||||
currentState.copy(messages = newMessages)
|
currentState.copy(messages = newMessages)
|
||||||
}
|
}
|
||||||
@@ -170,7 +187,11 @@ abstract class ChatPanel(
|
|||||||
}
|
}
|
||||||
if (newOnes.isNotEmpty()) {
|
if (newOnes.isNotEmpty()) {
|
||||||
updateState { currentState ->
|
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)
|
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
|
* Remove message from list
|
||||||
*/
|
*/
|
||||||
@@ -214,6 +269,23 @@ abstract class ChatPanel(
|
|||||||
updateState { it.copy(messages = emptyList()) }
|
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
|
* Set loading state
|
||||||
*/
|
*/
|
||||||
@@ -258,7 +330,7 @@ abstract class ChatPanel(
|
|||||||
mapped + confirmedMessage
|
mapped + confirmedMessage
|
||||||
else -> mapped
|
else -> mapped
|
||||||
}
|
}
|
||||||
currentState.copy(messages = messages)
|
currentState.copy(messages = sortMessagesForChatDisplay(messages))
|
||||||
}
|
}
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
runCatching { onOptimisticMessageConfirmed(tempId, confirmedMessage) }
|
runCatching { onOptimisticMessageConfirmed(tempId, confirmedMessage) }
|
||||||
@@ -274,7 +346,7 @@ abstract class ChatPanel(
|
|||||||
suspend fun retryMessage(messageId: Int) {
|
suspend fun retryMessage(messageId: Int) {
|
||||||
val message = _state.messages.find { it.id == messageId } ?: return
|
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(
|
val newOptimistic = message.copy(
|
||||||
id = uniqueOptimisticMessageId(),
|
id = uniqueOptimisticMessageId(),
|
||||||
client_message_id = tempId
|
client_message_id = tempId
|
||||||
@@ -334,12 +406,12 @@ abstract class ChatPanel(
|
|||||||
if (content.isBlank()) return
|
if (content.isBlank()) return
|
||||||
|
|
||||||
// Create temporary message for immediate display
|
// Create temporary message for immediate display
|
||||||
val tempId = "temp_${Clock.System.now().toEpochMilliseconds()}_${(0..999999).random()}"
|
val tempId = generateClientMessageId()
|
||||||
val tempMessage = Message(
|
val tempMessage = Message(
|
||||||
id = -1, // Temporary negative ID
|
id = -1, // Temporary negative ID
|
||||||
user_id = currentUserId ?: -1,
|
user_id = currentUserId ?: -1,
|
||||||
content = content.trim(),
|
content = content.trim(),
|
||||||
timestamp = Clock.System.now().toString(),
|
timestamp = nowMessageTimestampIso(),
|
||||||
is_read = false,
|
is_read = false,
|
||||||
is_edited = false,
|
is_edited = false,
|
||||||
username = "You",
|
username = "You",
|
||||||
@@ -391,6 +463,9 @@ abstract class ChatPanel(
|
|||||||
/** Persist optimistic row for offline / process death; no-op by default. */
|
/** Persist optimistic row for offline / process death; no-op by default. */
|
||||||
protected open suspend fun persistOptimisticMessage(message: Message) {}
|
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) {}
|
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.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.ExperimentalHazeMaterialsApi
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.AttachmentUploadJob
|
import ru.fromchat.api.AttachmentUploadNotifier
|
||||||
import ru.fromchat.api.AttachmentUploadQueue
|
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.ConnectionStateStore
|
||||||
import ru.fromchat.api.ConnectionStatus
|
import ru.fromchat.api.ConnectionStatus
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
@@ -220,7 +229,26 @@ fun ChatScreen(
|
|||||||
var expandedImage by remember { mutableStateOf<Pair<Message, Int>?>(null) }
|
var expandedImage by remember { mutableStateOf<Pair<Message, Int>?>(null) }
|
||||||
var isImageClosing by remember { mutableStateOf(false) }
|
var isImageClosing by remember { mutableStateOf(false) }
|
||||||
val imageThumbBounds = remember { mutableStateMapOf<String, Rect>() }
|
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
|
// Collect WebSocket messages
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -311,11 +339,27 @@ fun ChatScreen(
|
|||||||
var didInitialScroll by rememberSaveable(panelId) { mutableStateOf(false) }
|
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.
|
// 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("") }
|
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
|
if (panelState.messages.isEmpty()) return@LaunchedEffect
|
||||||
|
|
||||||
|
val newCount = panelState.messages.size
|
||||||
|
if (previousMessageCount >= 0 && newCount < previousMessageCount) {
|
||||||
|
previousMessageCount = newCount
|
||||||
val lastMessage = panelState.messages.lastOrNull()
|
val lastMessage = panelState.messages.lastOrNull()
|
||||||
val fingerprint = "${panelState.messages.size}|${lastMessage?.id}|${lastMessage?.pendingFileAspectRatio}"
|
previousMessageFingerprint =
|
||||||
|
"${newCount}|${lastMessage?.client_message_id}|${lastMessage?.uploadProgress}|${lastMessage?.pendingFileAspectRatio}"
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
previousMessageCount = newCount
|
||||||
|
|
||||||
|
val lastMessage = panelState.messages.lastOrNull()
|
||||||
|
val fingerprint = "${newCount}|${lastMessage?.client_message_id}|${lastMessage?.uploadProgress}|${lastMessage?.pendingFileAspectRatio}"
|
||||||
|
|
||||||
if (!didInitialScroll) {
|
if (!didInitialScroll) {
|
||||||
didInitialScroll = true
|
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) {
|
LaunchedEffect(panel) {
|
||||||
if (panel.getRecipientId() != null) {
|
if (panel.getRecipientId() != null) {
|
||||||
AttachmentUploadQueue.progressFlow.collect { progress ->
|
AttachmentUploadNotifier.progressFlow.collect { progress ->
|
||||||
when (progress) {
|
when (progress) {
|
||||||
is ru.fromchat.api.AttachmentUploadProgress.InProgress ->
|
is AttachmentUploadProgress.InProgress ->
|
||||||
panel.updateMessage(-progress.jobId.hashCode().let { if (it == 0) -1 else it }) {
|
panel.updateMessageByClientMessageId(progress.jobId) {
|
||||||
if (it.uploadJobId == progress.jobId) it.copy(uploadProgress = progress.percent) else it
|
it.copy(uploadProgress = progress.percent)
|
||||||
}
|
}
|
||||||
is ru.fromchat.api.AttachmentUploadProgress.Success ->
|
is AttachmentUploadProgress.Success ->
|
||||||
panel.updateMessage(-progress.jobId.hashCode().let { if (it == 0) -1 else it }) {
|
panel.updateMessageByClientMessageId(progress.jobId) {
|
||||||
if (it.uploadJobId == progress.jobId) it.copy(uploadProgress = null) else it
|
it.copy(uploadProgress = null)
|
||||||
}
|
}
|
||||||
else -> {}
|
is AttachmentUploadProgress.Failed -> {
|
||||||
|
if (progress.error != "Cancelled") {
|
||||||
|
scope.launch { panel.cancelQueuedMessageByClientId(progress.jobId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,16 +476,39 @@ fun ChatScreen(
|
|||||||
if (attachments.isNotEmpty() && recipientId != null) {
|
if (attachments.isNotEmpty() && recipientId != null) {
|
||||||
val plaintext = text.ifBlank { "" }
|
val plaintext = text.ifBlank { "" }
|
||||||
attachments.forEach { att ->
|
attachments.forEach { att ->
|
||||||
val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}"
|
val jobId = generateClientMessageId()
|
||||||
val hc = jobId.hashCode()
|
val tempId = optimisticMessageIdForClientMessageId(jobId)
|
||||||
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 isImage = att.isImage
|
val isImage = att.isImage
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
val imageDimensions = if (isImage) {
|
||||||
|
getImageDimensions(att.uri)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
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(
|
val optimisticMessage = Message(
|
||||||
id = tempId,
|
id = tempId,
|
||||||
user_id = currentUserId ?: -1,
|
user_id = currentUserId ?: -1,
|
||||||
content = plaintext.ifBlank { att.filename },
|
content = plaintext.ifBlank { att.filename },
|
||||||
timestamp = Clock.System.now().toString(),
|
timestamp = nowMessageTimestampIso(),
|
||||||
is_read = false,
|
is_read = false,
|
||||||
is_edited = false,
|
is_edited = false,
|
||||||
username = "You",
|
username = "You",
|
||||||
@@ -420,33 +518,34 @@ fun ChatScreen(
|
|||||||
client_message_id = jobId,
|
client_message_id = jobId,
|
||||||
reactions = null,
|
reactions = null,
|
||||||
files = null,
|
files = null,
|
||||||
pendingFileUri = att.uri,
|
pendingFileUri = fileUri,
|
||||||
pendingFilename = att.filename,
|
pendingFilename = att.filename,
|
||||||
uploadJobId = jobId,
|
uploadJobId = jobId,
|
||||||
uploadProgress = 0
|
uploadProgress = if (isImage) 0 else null,
|
||||||
|
pendingFileAspectRatio = staged?.aspectRatio ?: aspectRatio,
|
||||||
|
fileDimensions = imageDimensions?.let { listOf(it) },
|
||||||
)
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
panel.addMessage(optimisticMessage)
|
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
|
|
||||||
}
|
}
|
||||||
|
if (isImage && staged == null) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
panel.cancelQueuedMessageByClientId(jobId)
|
||||||
}
|
}
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
}
|
OutgoingMessageCoordinator.enqueueDmAttachment(
|
||||||
AttachmentUploadQueue.enqueue(
|
|
||||||
AttachmentUploadJob(
|
|
||||||
jobId = jobId,
|
|
||||||
fileUri = att.uri,
|
|
||||||
filename = att.filename,
|
|
||||||
recipientId = recipientId,
|
recipientId = recipientId,
|
||||||
plaintext = plaintext.ifBlank { att.filename },
|
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()) {
|
} else if (text.isNotBlank()) {
|
||||||
panel.sendMessageWithImmediateDisplay(text, replyToId)
|
panel.sendMessageWithImmediateDisplay(text, replyToId)
|
||||||
}
|
}
|
||||||
@@ -516,7 +615,8 @@ fun ChatScreen(
|
|||||||
items(
|
items(
|
||||||
items = panelState.messages.asReversed(),
|
items = panelState.messages.asReversed(),
|
||||||
key = { msg ->
|
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 ->
|
) { message ->
|
||||||
var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) }
|
var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) }
|
||||||
@@ -650,13 +750,23 @@ fun ChatScreen(
|
|||||||
panel.handleDeleteMessage(message.id)
|
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) ->
|
expandedImage?.let { (msg, idx) ->
|
||||||
val key = "img_${msg.id}_$idx"
|
val key = imageAttachmentKey(msg, idx)
|
||||||
ImageFullscreenPreview(
|
ImageFullscreenPreview(
|
||||||
message = msg,
|
message = msg,
|
||||||
fileIndex = idx,
|
fileIndex = idx,
|
||||||
|
|||||||
@@ -2,85 +2,458 @@ package ru.fromchat.ui.chat
|
|||||||
|
|
||||||
import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
import com.pr0gramm3r101.utils.files.PlatformFileSystem
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.DmEnvelope
|
import ru.fromchat.api.DmEnvelope
|
||||||
import ru.fromchat.api.DmFile
|
import ru.fromchat.api.DmFile
|
||||||
|
import ru.fromchat.api.AttachmentDownloadNotifier
|
||||||
|
import ru.fromchat.api.AttachmentDownloadProgress
|
||||||
import ru.fromchat.crypto.decryptFile
|
import ru.fromchat.crypto.decryptFile
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disk cache for decrypted image bytes. Key includes messageId, fileIndex, and file.path
|
* Disk + in-memory cache for decrypted DM images.
|
||||||
* so that server updates (e.g. image replacement) produce cache misses via path change.
|
* Key is stable per attachment slot ([client_message_id] + index, or [messageId] + index).
|
||||||
*/
|
*/
|
||||||
object DecryptedImageCache {
|
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 var cacheDir: String? = null
|
||||||
private val mutex = Mutex()
|
private val cacheMutex = Mutex()
|
||||||
|
private val memoryCache = mutableMapOf<String, String>()
|
||||||
|
|
||||||
fun init(cacheDirPath: String) {
|
fun init(cacheDirPath: String) {
|
||||||
cacheDir = cacheDirPath
|
cacheDir = cacheDirPath
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureCacheDir(): String? {
|
/**
|
||||||
if (cacheDir == null) {
|
* Stable identity for one attachment slot. Does not include [filePath] so confirm/scroll
|
||||||
val base = PlatformFileSystem.getAppCacheDirectory()
|
* cannot miss cache when the server path string varies; edits/deletes invalidate explicitly.
|
||||||
if (base.isEmpty()) return null
|
*/
|
||||||
cacheDir = PlatformFileSystem.ensureDirectory("$base/decrypted_images")
|
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 {
|
/** Keys that may refer to the same slot (progress UI + cache aliases). */
|
||||||
val safePath = filePath.hashCode().toString(36).replace("-", "m")
|
fun progressLookupKeys(
|
||||||
return "img_${messageId}_${fileIndex}_$safePath"
|
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? {
|
/** Synchronous disk lookup (safe to call from the main thread during composition). */
|
||||||
val dir = ensureCacheDir() ?: return null
|
fun getCached(
|
||||||
val path = "$dir/${key(messageId, fileIndex, filePath)}"
|
messageId: Int,
|
||||||
return if (PlatformFileSystem.exists(path)) "file://$path" else null
|
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(
|
suspend fun getOrDecrypt(
|
||||||
messageId: Int,
|
messageId: Int,
|
||||||
fileIndex: Int,
|
fileIndex: Int,
|
||||||
file: DmFile,
|
file: DmFile,
|
||||||
envelope: DmEnvelope?,
|
envelope: DmEnvelope?,
|
||||||
currentUserId: Int?
|
currentUserId: Int?,
|
||||||
|
clientMessageId: String? = null,
|
||||||
|
messageLabel: String? = null,
|
||||||
): String? {
|
): String? {
|
||||||
if (envelope == null) return null
|
if (envelope == null) return null
|
||||||
val dir = ensureCacheDir() ?: return null
|
val key = storageKey(messageId, fileIndex, clientMessageId)
|
||||||
val k = key(messageId, fileIndex, file.path)
|
|
||||||
val path = "$dir/$k"
|
|
||||||
|
|
||||||
mutex.withLock {
|
getCached(messageId, fileIndex, clientMessageId)?.let { uri ->
|
||||||
if (PlatformFileSystem.exists(path)) return "file://$path"
|
AttachmentMediaLog.diskCache(
|
||||||
|
"getOrDecrypt_hit",
|
||||||
|
"key" to key,
|
||||||
|
"msgId" to messageId,
|
||||||
|
"clientId" to clientMessageId,
|
||||||
|
"uri" to uri,
|
||||||
|
)
|
||||||
|
return uri
|
||||||
}
|
}
|
||||||
|
|
||||||
val bytes = runCatching {
|
AttachmentMediaLog.diskCache(
|
||||||
withContext(Dispatchers.Default) {
|
"getOrDecrypt_miss",
|
||||||
decryptFile(file, envelope, currentUserId)
|
"key" to key,
|
||||||
}
|
"msgId" to messageId,
|
||||||
}.getOrNull() ?: return null
|
"clientId" to clientMessageId,
|
||||||
|
"file" to file.path,
|
||||||
|
)
|
||||||
|
|
||||||
mutex.withLock {
|
cacheMutex.withLock { resolveUriLocked(key) }?.let { return it }
|
||||||
PlatformFileSystem.writeBytes(path, bytes)
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return "file://$path"
|
}.onFailure { error ->
|
||||||
|
ApiClient.clearPartialEncryptedDownload(key)
|
||||||
|
AttachmentMediaLog.download(
|
||||||
|
"decrypt_exception",
|
||||||
|
"key" to key,
|
||||||
|
"msgId" to messageId,
|
||||||
|
"msg" to label,
|
||||||
|
"err" to (error.message ?: error::class.simpleName),
|
||||||
|
)
|
||||||
|
AttachmentDownloadNotifier.emit(
|
||||||
|
AttachmentDownloadProgress.Failed(
|
||||||
|
storageKey = key,
|
||||||
|
error = error.message ?: "decrypt_failed",
|
||||||
|
),
|
||||||
|
messageLabel = label,
|
||||||
|
messageId = messageId,
|
||||||
|
fileIndex = fileIndex,
|
||||||
|
clientMessageId = clientMessageId,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
if (uri != null && messageId > 0) {
|
||||||
|
ensureDiskAliasForMessageId(messageId, fileIndex, clientMessageId)
|
||||||
|
}
|
||||||
|
return uri
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun invalidateForMessage(messageId: Int) {
|
suspend fun invalidateForMessage(messageId: Int) {
|
||||||
val dir = ensureCacheDir() ?: return
|
val dir = ensureCacheDir() ?: return
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
|
cacheMutex.withLock {
|
||||||
|
memoryCache.keys.removeAll { it.startsWith("img_${messageId}_") }
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
PlatformFileSystem.deleteFilesWithPrefix(dir, "img_${messageId}_")
|
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 dir = ensureCacheDir() ?: return
|
||||||
val path = "$dir/${key(messageId, fileIndex, filePath)}"
|
|
||||||
withContext(Dispatchers.Default) {
|
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.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.joinAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.api.formatMessageDateTimeLocal
|
||||||
import ru.fromchat.*
|
import ru.fromchat.*
|
||||||
import ru.fromchat.ui.BackHandler
|
import ru.fromchat.ui.BackHandler
|
||||||
import ru.fromchat.ui.LocalSystemBarsVisibility
|
import ru.fromchat.ui.LocalSystemBarsVisibility
|
||||||
@@ -78,9 +79,6 @@ import kotlin.math.abs
|
|||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.time.ExperimentalTime
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
private val MENU_BG_ALPHA = 0.5f
|
private val MENU_BG_ALPHA = 0.5f
|
||||||
|
|
||||||
private data class InitialTransform(
|
private data class InitialTransform(
|
||||||
@@ -91,22 +89,6 @@ private data class InitialTransform(
|
|||||||
val bgAlpha: Float
|
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
|
@Composable
|
||||||
fun ImageFullscreenPreview(
|
fun ImageFullscreenPreview(
|
||||||
message: Message,
|
message: Message,
|
||||||
@@ -133,18 +115,33 @@ fun ImageFullscreenPreview(
|
|||||||
val envelope = message.dmEnvelope
|
val envelope = message.dmEnvelope
|
||||||
val thumbnailBase64 = message.fileThumbnails?.getOrNull(fileIndex)
|
val thumbnailBase64 = message.fileThumbnails?.getOrNull(fileIndex)
|
||||||
|
|
||||||
var cachedPath by remember(message.id, fileIndex, file.path) {
|
val cacheClientId = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
mutableStateOf(DecryptedImageCache.getCached(message.id, fileIndex, file.path))
|
val decryptCacheKey = remember(message.id, fileIndex, cacheClientId) {
|
||||||
|
DecryptedImageCache.storageKey(message.id, fileIndex, cacheClientId)
|
||||||
}
|
}
|
||||||
val thumbnailBytes = remember(thumbnailBase64) {
|
val fsCacheKey = remember(decryptCacheKey) {
|
||||||
thumbnailBase64?.let { runCatching { com.pr0gramm3r101.utils.crypto.Base64.decode(it) }.getOrNull() }
|
LocalDecodedImageCache.fullscreenCacheKey(decryptCacheKey)
|
||||||
}
|
}
|
||||||
|
var cachedPath by remember(decryptCacheKey) {
|
||||||
LaunchedEffect(message.id, fileIndex, file.path, envelope) {
|
mutableStateOf(DecryptedImageCache.getCached(message.id, fileIndex, cacheClientId))
|
||||||
cachedPath = DecryptedImageCache.getOrDecrypt(message.id, fileIndex, file, envelope, currentUserId)
|
|
||||||
}
|
}
|
||||||
|
var previewBitmap by remember(decryptCacheKey) {
|
||||||
val imageModel = cachedPath ?: thumbnailBytes
|
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 menusVisible by remember { mutableStateOf(true) }
|
||||||
var dismissRequested by remember { mutableStateOf(false) }
|
var dismissRequested by remember { mutableStateOf(false) }
|
||||||
@@ -186,11 +183,45 @@ fun ImageFullscreenPreview(
|
|||||||
) {
|
) {
|
||||||
val containerWidth = constraints.maxWidth.toFloat()
|
val containerWidth = constraints.maxWidth.toFloat()
|
||||||
val containerHeight = constraints.maxHeight.toFloat()
|
val containerHeight = constraints.maxHeight.toFloat()
|
||||||
val fileAspectRatio = message.fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }
|
val fullscreenDecodeSize = remember(constraints.maxWidth, constraints.maxHeight) {
|
||||||
val contentHeightAtScale1 = if (fileAspectRatio != null) containerWidth / fileAspectRatio else containerHeight
|
ChatPreviewDecodeSize(
|
||||||
|
widthPx = constraints.maxWidth.coerceAtLeast(1),
|
||||||
|
heightPx = constraints.maxHeight.coerceAtLeast(1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
when (val model = imageModel) {
|
LaunchedEffect(decryptCacheKey, fsCacheKey, fullscreenDecodeSize) {
|
||||||
null -> {
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -205,14 +236,23 @@ fun ImageFullscreenPreview(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
val layoutAspect = openAspectFromBitmap
|
||||||
|
?: thumbLayoutAspect
|
||||||
|
?: layoutAspectHint
|
||||||
|
?: displayAspect
|
||||||
|
val layoutContentHeight = if (layoutAspect != null) {
|
||||||
|
containerWidth / layoutAspect
|
||||||
|
} else {
|
||||||
|
contentHeightAtScale1
|
||||||
|
}
|
||||||
val initial = remember(
|
val initial = remember(
|
||||||
thumbnailBounds, containerWidth, containerHeight, contentHeightAtScale1
|
thumbnailBounds, containerWidth, containerHeight, layoutContentHeight,
|
||||||
) {
|
) {
|
||||||
if (thumbnailBounds != null && fileAspectRatio != null) {
|
if (thumbnailBounds != null && layoutAspect != null) {
|
||||||
val fullTop = (containerHeight - contentHeightAtScale1) / 2f
|
val fullTop = (containerHeight - layoutContentHeight) / 2f
|
||||||
val fullCenter = Offset(
|
val fullCenter = Offset(
|
||||||
x = containerWidth / 2f,
|
x = containerWidth / 2f,
|
||||||
y = fullTop + contentHeightAtScale1 / 2f
|
y = fullTop + layoutContentHeight / 2f,
|
||||||
)
|
)
|
||||||
val thumbCenter = thumbnailBounds.center
|
val thumbCenter = thumbnailBounds.center
|
||||||
val thumbWidth = thumbnailBounds.width
|
val thumbWidth = thumbnailBounds.width
|
||||||
@@ -244,10 +284,10 @@ fun ImageFullscreenPreview(
|
|||||||
hasPlayedOpenAnimation = true
|
hasPlayedOpenAnimation = true
|
||||||
isOpenAnimationPlaying = true
|
isOpenAnimationPlaying = true
|
||||||
|
|
||||||
val fullTop = (containerHeight - contentHeightAtScale1) / 2f
|
val fullTop = (containerHeight - layoutContentHeight) / 2f
|
||||||
val fullCenter = Offset(
|
val fullCenter = Offset(
|
||||||
x = containerWidth / 2f,
|
x = containerWidth / 2f,
|
||||||
y = fullTop + contentHeightAtScale1 / 2f
|
y = fullTop + layoutContentHeight / 2f,
|
||||||
)
|
)
|
||||||
val thumbCenter = thumbnailBounds.center
|
val thumbCenter = thumbnailBounds.center
|
||||||
val thumbWidth = thumbnailBounds.width
|
val thumbWidth = thumbnailBounds.width
|
||||||
@@ -261,17 +301,24 @@ fun ImageFullscreenPreview(
|
|||||||
offsetXAnim.snapTo(startOffset.x)
|
offsetXAnim.snapTo(startOffset.x)
|
||||||
offsetYAnim.snapTo(startOffset.y)
|
offsetYAnim.snapTo(startOffset.y)
|
||||||
cornerRadiusAnim.snapTo(12f)
|
cornerRadiusAnim.snapTo(12f)
|
||||||
backgroundAlpha.snapTo(0f)
|
backgroundAlpha.snapTo(if (hasInstantBitmap) 1f else 0f)
|
||||||
menusVisible = false
|
menusVisible = false
|
||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
launch { scaleAnim.animateTo(1f, tween(250)) }
|
joinAll(
|
||||||
launch { offsetXAnim.animateTo(0f, tween(250)) }
|
launch { scaleAnim.animateTo(1f, tween(280)) },
|
||||||
launch { offsetYAnim.animateTo(0f, tween(250)) }
|
launch { offsetXAnim.animateTo(0f, tween(280)) },
|
||||||
launch { cornerRadiusAnim.animateTo(0f, tween(250)) }
|
launch { offsetYAnim.animateTo(0f, tween(280)) },
|
||||||
launch { backgroundAlpha.animateTo(1f, tween(250)) }
|
launch { cornerRadiusAnim.animateTo(0f, tween(280)) },
|
||||||
|
launch {
|
||||||
|
if (!hasInstantBitmap) {
|
||||||
|
backgroundAlpha.animateTo(1f, tween(280))
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
scale = 1f
|
||||||
|
offset = Offset.Zero
|
||||||
isOpenAnimationPlaying = false
|
isOpenAnimationPlaying = false
|
||||||
menusVisible = true
|
menusVisible = true
|
||||||
}
|
}
|
||||||
@@ -296,10 +343,10 @@ fun ImageFullscreenPreview(
|
|||||||
val wasOpenAnimating = isOpenAnimationPlaying
|
val wasOpenAnimating = isOpenAnimationPlaying
|
||||||
isOpenAnimationPlaying = false
|
isOpenAnimationPlaying = false
|
||||||
|
|
||||||
val fullTop = (containerHeight - contentHeightAtScale1) / 2f
|
val fullTop = (containerHeight - layoutContentHeight) / 2f
|
||||||
val fullCenter = Offset(
|
val fullCenter = Offset(
|
||||||
x = containerWidth / 2f,
|
x = containerWidth / 2f,
|
||||||
y = fullTop + contentHeightAtScale1 / 2f
|
y = fullTop + layoutContentHeight / 2f,
|
||||||
)
|
)
|
||||||
val thumbCenter = thumbnailBounds.center
|
val thumbCenter = thumbnailBounds.center
|
||||||
val thumbWidth = thumbnailBounds.width
|
val thumbWidth = thumbnailBounds.width
|
||||||
@@ -385,7 +432,7 @@ fun ImageFullscreenPreview(
|
|||||||
|
|
||||||
val clampedScale = scale.coerceIn(1f, 10f)
|
val clampedScale = scale.coerceIn(1f, 10f)
|
||||||
val scaledW = containerWidth * clampedScale
|
val scaledW = containerWidth * clampedScale
|
||||||
val scaledH = contentHeightAtScale1 * clampedScale
|
val scaledH = layoutContentHeight * clampedScale
|
||||||
val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f)
|
val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f)
|
||||||
val maxOffsetY = max(0f, (scaledH - containerHeight) / 2f)
|
val maxOffsetY = max(0f, (scaledH - containerHeight) / 2f)
|
||||||
val clampedOffset = when {
|
val clampedOffset = when {
|
||||||
@@ -420,7 +467,10 @@ fun ImageFullscreenPreview(
|
|||||||
val raw = (abs(dragY) / (containerHeight * 0.75f)).coerceIn(0f, 1f)
|
val raw = (abs(dragY) / (containerHeight * 0.75f)).coerceIn(0f, 1f)
|
||||||
raw * raw
|
raw * raw
|
||||||
} else 0f
|
} 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 }
|
SideEffect { effectiveBgAlpha = effectiveBackgroundAlpha }
|
||||||
|
|
||||||
val sharedElementModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) {
|
val sharedElementModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) {
|
||||||
@@ -431,15 +481,18 @@ fun ImageFullscreenPreview(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else Modifier
|
} else Modifier
|
||||||
val sizeModifier = if (fileAspectRatio != null) Modifier.fillMaxWidth().aspectRatio(fileAspectRatio)
|
val sizeModifier = if (layoutAspect != null) {
|
||||||
else Modifier.fillMaxSize()
|
Modifier.fillMaxWidth().aspectRatio(layoutAspect)
|
||||||
|
} else {
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.then(
|
.then(
|
||||||
if (isTransitioning) Modifier
|
if (isTransitioning) Modifier
|
||||||
else Modifier.pointerInput(
|
else Modifier.pointerInput(
|
||||||
containerWidth, containerHeight, contentHeightAtScale1,
|
containerWidth, containerHeight, layoutContentHeight,
|
||||||
bottomInsetPx, scope
|
bottomInsetPx, scope
|
||||||
) {
|
) {
|
||||||
detectTransformGestures(
|
detectTransformGestures(
|
||||||
@@ -474,7 +527,7 @@ fun ImageFullscreenPreview(
|
|||||||
centroid.y - centerY - pivotOffsetY * zoomChange
|
centroid.y - centerY - pivotOffsetY * zoomChange
|
||||||
)
|
)
|
||||||
|
|
||||||
val contentHeightAtNewScale = contentHeightAtScale1 * newScale
|
val contentHeightAtNewScale = layoutContentHeight * newScale
|
||||||
val scaledW = containerWidth * newScale
|
val scaledW = containerWidth * newScale
|
||||||
val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f)
|
val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f)
|
||||||
val maxOffsetYRaw = max(0f, (contentHeightAtNewScale - containerHeight) / 2f)
|
val maxOffsetYRaw = max(0f, (contentHeightAtNewScale - containerHeight) / 2f)
|
||||||
@@ -496,7 +549,7 @@ fun ImageFullscreenPreview(
|
|||||||
}
|
}
|
||||||
offset = Offset(newX, newY)
|
offset = Offset(newX, newY)
|
||||||
} else {
|
} else {
|
||||||
val contentHeightAtCurrentScale = contentHeightAtScale1 * scale
|
val contentHeightAtCurrentScale = layoutContentHeight * scale
|
||||||
val canScrollVertically = contentHeightAtCurrentScale > containerHeight
|
val canScrollVertically = contentHeightAtCurrentScale > containerHeight
|
||||||
val absDx = abs(panChange.x)
|
val absDx = abs(panChange.x)
|
||||||
val absDy = abs(panChange.y)
|
val absDy = abs(panChange.y)
|
||||||
@@ -564,8 +617,8 @@ fun ImageFullscreenPreview(
|
|||||||
.then(sharedElementModifier)
|
.then(sharedElementModifier)
|
||||||
.offset { IntOffset(offsetXAnim.value.roundToInt(), offsetYAnim.value.roundToInt()) }
|
.offset { IntOffset(offsetXAnim.value.roundToInt(), offsetYAnim.value.roundToInt()) }
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
FullscreenBitmapImage(
|
||||||
model = model,
|
bitmap = displayBitmap,
|
||||||
contentDescription = file.name,
|
contentDescription = file.name,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -578,7 +631,7 @@ fun ImageFullscreenPreview(
|
|||||||
shape = androidx.compose.foundation.shape.RoundedCornerShape((cornerRadiusAnim.value / scale).dp)
|
shape = androidx.compose.foundation.shape.RoundedCornerShape((cornerRadiusAnim.value / scale).dp)
|
||||||
clip = true
|
clip = true
|
||||||
},
|
},
|
||||||
contentScale = ContentScale.FillWidth
|
contentScale = ContentScale.Fit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -587,17 +640,22 @@ fun ImageFullscreenPreview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Top bar: back, display name + date/time, 3-dot menu
|
// Top bar: back, display name + date/time, 3-dot menu
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.systemBars),
|
||||||
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = effectiveMenusVisible,
|
visible = effectiveMenusVisible,
|
||||||
enter = androidx.compose.animation.fadeIn(),
|
enter = androidx.compose.animation.fadeIn(),
|
||||||
exit = androidx.compose.animation.fadeOut(),
|
exit = androidx.compose.animation.fadeOut(),
|
||||||
modifier = Modifier.align(Alignment.TopStart).fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
|
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
|
||||||
.windowInsetsPadding(WindowInsets.systemBars)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
@@ -619,7 +677,7 @@ fun ImageFullscreenPreview(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatDateTime(message.timestamp),
|
text = formatMessageDateTimeLocal(message.timestamp),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Color.White.copy(alpha = 0.8f)
|
color = Color.White.copy(alpha = 0.8f)
|
||||||
)
|
)
|
||||||
@@ -683,21 +741,27 @@ fun ImageFullscreenPreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bottom: message text
|
// Bottom: message text
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.systemBars),
|
||||||
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = effectiveMenusVisible && message.content.isNotBlank(),
|
visible = effectiveMenusVisible && message.content.isNotBlank(),
|
||||||
enter = androidx.compose.animation.fadeIn(),
|
enter = androidx.compose.animation.fadeIn(),
|
||||||
exit = androidx.compose.animation.fadeOut(),
|
exit = androidx.compose.animation.fadeOut(),
|
||||||
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (message.content.isNotBlank()) {
|
if (message.content.isNotBlank()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
|
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
|
||||||
.windowInsetsPadding(WindowInsets.systemBars)
|
.padding(16.dp),
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
@@ -709,3 +773,4 @@ fun ImageFullscreenPreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.Reply
|
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.Delete
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
import androidx.compose.material.icons.rounded.Edit
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -49,6 +51,7 @@ import androidx.compose.ui.window.PopupProperties
|
|||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.api.isQueuedOutbound
|
||||||
import ru.fromchat.*
|
import ru.fromchat.*
|
||||||
import ru.fromchat.ui.scaleOnPress
|
import ru.fromchat.ui.scaleOnPress
|
||||||
|
|
||||||
@@ -58,6 +61,25 @@ data class ContextMenuState(
|
|||||||
val position: IntOffset = IntOffset(0, 0)
|
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")
|
@Suppress("AssignedValueIsNeverRead")
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageContextMenu(
|
fun MessageContextMenu(
|
||||||
@@ -67,6 +89,8 @@ fun MessageContextMenu(
|
|||||||
onReply: (Message) -> Unit,
|
onReply: (Message) -> Unit,
|
||||||
onEdit: (Message) -> Unit,
|
onEdit: (Message) -> Unit,
|
||||||
onDelete: (Message) -> Unit,
|
onDelete: (Message) -> Unit,
|
||||||
|
onCopy: (Message) -> Unit,
|
||||||
|
onCancelSend: (Message) -> Unit,
|
||||||
isReadOnly: Boolean = false,
|
isReadOnly: Boolean = false,
|
||||||
screenWidthPx: Int,
|
screenWidthPx: Int,
|
||||||
screenHeightPx: Int,
|
screenHeightPx: Int,
|
||||||
@@ -126,6 +150,8 @@ fun MessageContextMenu(
|
|||||||
onReply = {},
|
onReply = {},
|
||||||
onEdit = {},
|
onEdit = {},
|
||||||
onDelete = {},
|
onDelete = {},
|
||||||
|
onCopy = {},
|
||||||
|
onCancelSend = {},
|
||||||
modifier = modifier.graphicsLayer(alpha = 0f),
|
modifier = modifier.graphicsLayer(alpha = 0f),
|
||||||
animated = false,
|
animated = false,
|
||||||
withShadow = false,
|
withShadow = false,
|
||||||
@@ -212,6 +238,14 @@ fun MessageContextMenu(
|
|||||||
onDelete(it)
|
onDelete(it)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
|
onCopy = {
|
||||||
|
onCopy(it)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
onCancelSend = {
|
||||||
|
onCancelSend(it)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
animated = true,
|
animated = true,
|
||||||
scale = scale,
|
scale = scale,
|
||||||
@@ -232,6 +266,8 @@ private fun ContextMenuContent(
|
|||||||
onReply: (Message) -> Unit,
|
onReply: (Message) -> Unit,
|
||||||
onEdit: (Message) -> Unit,
|
onEdit: (Message) -> Unit,
|
||||||
onDelete: (Message) -> Unit,
|
onDelete: (Message) -> Unit,
|
||||||
|
onCopy: (Message) -> Unit,
|
||||||
|
onCancelSend: (Message) -> Unit,
|
||||||
isReadOnly: Boolean = false,
|
isReadOnly: Boolean = false,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
animated: Boolean,
|
animated: Boolean,
|
||||||
@@ -276,6 +312,9 @@ private fun ContextMenuContent(
|
|||||||
val labelReply = stringResource(Res.string.action_reply)
|
val labelReply = stringResource(Res.string.action_reply)
|
||||||
val labelEdit = stringResource(Res.string.action_edit)
|
val labelEdit = stringResource(Res.string.action_edit)
|
||||||
val labelDelete = stringResource(Res.string.action_delete)
|
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 = containerModifier) {
|
||||||
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape))
|
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape))
|
||||||
@@ -285,21 +324,34 @@ private fun ContextMenuContent(
|
|||||||
.verticalScroll(menuScrollState),
|
.verticalScroll(menuScrollState),
|
||||||
verticalArrangement = Arrangement.spacedBy(itemSpacing)
|
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(
|
ContextMenuItem(
|
||||||
icon = Icons.AutoMirrored.Rounded.Reply,
|
icon = Icons.AutoMirrored.Rounded.Reply,
|
||||||
text = labelReply,
|
text = labelReply,
|
||||||
onClick = { onReply(message) }
|
onClick = { onReply(message) }
|
||||||
)
|
)
|
||||||
}
|
if (isAuthor && !message.isContentCorrupted) {
|
||||||
if (isAuthor && !isReadOnly) {
|
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
icon = Icons.Rounded.Edit,
|
icon = Icons.Rounded.Edit,
|
||||||
text = labelEdit,
|
text = labelEdit,
|
||||||
onClick = { onEdit(message) }
|
onClick = { onEdit(message) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isAuthor && !isReadOnly) {
|
if (isAuthor) {
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
icon = Icons.Rounded.Delete,
|
icon = Icons.Rounded.Delete,
|
||||||
text = labelDelete,
|
text = labelDelete,
|
||||||
@@ -310,6 +362,7 @@ private fun ContextMenuContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val itemShape = RoundedCornerShape(12.dp)
|
private val itemShape = RoundedCornerShape(12.dp)
|
||||||
|
|
||||||
@@ -339,7 +392,11 @@ private fun ContextMenuItem(
|
|||||||
.scaleOnPress(
|
.scaleOnPress(
|
||||||
scale = 0.96f,
|
scale = 0.96f,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
indication = LocalIndication.current
|
indication = LocalIndication.current,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
contentAlignment = Alignment.CenterStart
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.pr0gramm3r101.utils.conditional
|
import com.pr0gramm3r101.utils.conditional
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.api.formatMessageTimeLocal
|
||||||
import ru.fromchat.*
|
import ru.fromchat.*
|
||||||
import kotlin.time.ExperimentalTime
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
private fun isMessageCorrupted(message: Message): Boolean {
|
private fun isMessageCorrupted(message: Message): Boolean {
|
||||||
val files = message.files ?: return false
|
val files = message.files ?: return false
|
||||||
@@ -70,7 +67,6 @@ private fun isMessageCorrupted(message: Message): Boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageItem(
|
fun MessageItem(
|
||||||
message: Message,
|
message: Message,
|
||||||
@@ -96,7 +92,7 @@ fun MessageItem(
|
|||||||
isMessageCorrupted(message)
|
isMessageCorrupted(message)
|
||||||
}
|
}
|
||||||
val formattedTime = remember(message.timestamp) {
|
val formattedTime = remember(message.timestamp) {
|
||||||
formatTime(message.timestamp)
|
formatMessageTimeLocal(message.timestamp)
|
||||||
}
|
}
|
||||||
val corruptedBody = stringResource(Res.string.message_corrupted)
|
val corruptedBody = stringResource(Res.string.message_corrupted)
|
||||||
val editedSuffix = stringResource(Res.string.message_edited_suffix)
|
val editedSuffix = stringResource(Res.string.message_edited_suffix)
|
||||||
@@ -419,84 +415,86 @@ fun MessageItem(
|
|||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val firstFile = message.files?.firstOrNull()
|
val primaryFile = message.files?.firstOrNull()
|
||||||
val firstFileIsImage = firstFile?.let { isImageFilename(it.name) } ?: false
|
val primaryIsImage = primaryFile != null && isImageFilename(primaryFile.name)
|
||||||
val hasPendingServerImage = message.pendingFileUri != null &&
|
val showPrimaryImageSlot = pendingIsImage || primaryIsImage
|
||||||
firstFileIsImage &&
|
if (showPrimaryImageSlot) {
|
||||||
message.dmEnvelope != null
|
val imageKey = imageAttachmentKey(message, 0)
|
||||||
if (message.pendingFileUri != null) {
|
val awaitingServer = message.id < 0 && message.files.isNullOrEmpty()
|
||||||
val isPendingImage = message.pendingFilename?.let { isImageFilename(it) } ?: false
|
val isOutboundPendingImage = awaitingServer && pendingIsImage
|
||||||
val pendingImageFile = firstFile.takeIf { isPendingImage && hasPendingServerImage }
|
val awaitingServerAck = isOutboundPendingImage &&
|
||||||
val imageKey = if (isPendingImage) "img_${message.id}_0" else null
|
message.uploadProgress == null
|
||||||
AttachmentPreview(
|
AttachmentPreview(
|
||||||
file = pendingImageFile,
|
file = primaryFile,
|
||||||
dmEnvelope = if (pendingImageFile != null) message.dmEnvelope else null,
|
dmEnvelope = message.dmEnvelope,
|
||||||
currentUserId = if (pendingImageFile != null) currentUserId else null,
|
currentUserId = currentUserId,
|
||||||
pendingFileUri = message.pendingFileUri,
|
pendingFileUri = message.pendingFileUri,
|
||||||
pendingFilename = message.pendingFilename,
|
pendingFilename = message.pendingFilename,
|
||||||
isUploading = message.uploadProgress != null,
|
isUploading = isOutboundPendingImage,
|
||||||
|
awaitingServerAck = awaitingServerAck,
|
||||||
uploadProgress = message.uploadProgress,
|
uploadProgress = message.uploadProgress,
|
||||||
fileThumbnail = if (pendingImageFile != null) {
|
fileThumbnail = message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() },
|
||||||
message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() }
|
fileAspectRatio = imageAspectRatioForMessage(
|
||||||
} else {
|
fileAspectRatios = message.fileAspectRatios,
|
||||||
null
|
fileDimensions = message.fileDimensions,
|
||||||
},
|
pendingFileAspectRatio = message.pendingFileAspectRatio,
|
||||||
fileAspectRatio = if (pendingImageFile != null) {
|
fileIndex = 0,
|
||||||
message.fileAspectRatios?.firstOrNull()?.takeIf { it > 0f }
|
confirmed = message.id > 0,
|
||||||
?: message.pendingFileAspectRatio
|
hasLocalPreview = DecryptedImageCache.isDecryptedImageCacheUri(
|
||||||
} else {
|
message.pendingFileUri,
|
||||||
message.pendingFileAspectRatio
|
),
|
||||||
},
|
),
|
||||||
fileSizeBytes = when {
|
fileSizeBytes = message.fileSizes?.firstOrNull(),
|
||||||
pendingImageFile != null -> message.fileSizes?.firstOrNull()
|
messageId = message.id,
|
||||||
!isPendingImage -> message.fileSizes?.firstOrNull()
|
fileIndex = 0,
|
||||||
else -> null
|
clientMessageId = message.client_message_id,
|
||||||
},
|
|
||||||
messageId = if (pendingImageFile != null && isPendingImage) message.id else null,
|
|
||||||
fileIndex = if (pendingImageFile != null && isPendingImage) 0 else null,
|
|
||||||
onFileClick = null,
|
onFileClick = null,
|
||||||
onImageClick = if (isPendingImage && imageKey != null) {
|
onImageClick = { onImageClick?.invoke(message, 0) },
|
||||||
{ onImageClick?.invoke(message, 0) }
|
onImageBounds = if (onImageBounds != null) {
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onImageBounds = if (isPendingImage && imageKey != null && onImageBounds != null) {
|
|
||||||
{ rect -> onImageBounds.invoke(imageKey, rect) }
|
{ rect -> onImageBounds.invoke(imageKey, rect) }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
isExpanded = isPendingImage &&
|
isExpanded = expandedImageKey != null &&
|
||||||
imageKey != null &&
|
|
||||||
expandedImageKey != null &&
|
|
||||||
expandedImageKey == imageKey &&
|
expandedImageKey == imageKey &&
|
||||||
!isImageClosing,
|
!isImageClosing,
|
||||||
isAuthor = isAuthor,
|
isAuthor = isAuthor,
|
||||||
modifier = if (isPendingImage && firstContentIsImage) {
|
messageLabel = message.content,
|
||||||
|
modifier = if (firstContentIsImage) {
|
||||||
Modifier.padding(all = 2.dp)
|
Modifier.padding(all = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
Modifier.padding(
|
Modifier.padding(horizontal = 2.dp, vertical = 4.dp)
|
||||||
horizontal = if (isPendingImage) 2.dp else 12.dp,
|
|
||||||
vertical = if (isPendingImage) 2.dp else 4.dp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
message.files?.forEachIndexed { index, file ->
|
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 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
|
val isFirstImage = index == 0 && isImage
|
||||||
AttachmentPreview(
|
AttachmentPreview(
|
||||||
file = file,
|
file = file,
|
||||||
dmEnvelope = message.dmEnvelope,
|
dmEnvelope = message.dmEnvelope,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
pendingFileUri = null,
|
pendingFileUri = if (index == 0) message.pendingFileUri else null,
|
||||||
|
pendingFilename = if (index == 0) message.pendingFilename else null,
|
||||||
isUploading = false,
|
isUploading = false,
|
||||||
fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() },
|
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),
|
fileSizeBytes = message.fileSizes?.getOrNull(index),
|
||||||
messageId = if (isImage) message.id else null,
|
messageId = if (isImage) message.id else null,
|
||||||
fileIndex = if (isImage) index else null,
|
fileIndex = if (isImage) index else null,
|
||||||
|
clientMessageId = message.client_message_id,
|
||||||
onFileClick = null,
|
onFileClick = null,
|
||||||
onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null,
|
onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null,
|
||||||
onImageBounds = if (isImage && imageKey != null && onImageBounds != null) {
|
onImageBounds = if (isImage && imageKey != null && onImageBounds != null) {
|
||||||
@@ -504,6 +502,7 @@ fun MessageItem(
|
|||||||
} else null,
|
} else null,
|
||||||
isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing,
|
isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing,
|
||||||
isAuthor = isAuthor,
|
isAuthor = isAuthor,
|
||||||
|
messageLabel = message.content,
|
||||||
modifier = if (isFirstImage && firstContentIsImage && isImage) {
|
modifier = if (isFirstImage && firstContentIsImage && isImage) {
|
||||||
Modifier.padding(all = 2.dp)
|
Modifier.padding(all = 2.dp)
|
||||||
} else {
|
} 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(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -529,14 +533,14 @@ fun MessageItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Timestamp, sending indicator, and edited indicator
|
// Timestamp, sending indicator, and edited indicator
|
||||||
val isSendingText = message.id < 0 && message.uploadJobId == null
|
val isPendingOutbound = message.id < 0 && message.files.isNullOrEmpty()
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
|
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (isSendingText) {
|
if (isPendingOutbound) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(12.dp),
|
modifier = Modifier.size(12.dp),
|
||||||
strokeWidth = 1.5.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.WebSocketMessage
|
||||||
import ru.fromchat.api.WebSocketUpdatesData
|
import ru.fromchat.api.WebSocketUpdatesData
|
||||||
import ru.fromchat.api.db.MessageCacheStore
|
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
|
import ru.fromchat.core.Logger
|
||||||
|
|
||||||
class PublicChatPanel(
|
class PublicChatPanel(
|
||||||
@@ -142,7 +146,15 @@ class PublicChatPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) {
|
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) {
|
override suspend fun persistOptimisticMessage(message: Message) {
|
||||||
@@ -307,7 +319,7 @@ class PublicChatPanel(
|
|||||||
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
|
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
|
||||||
removeMessage(deletedData.message_id)
|
removeMessage(deletedData.message_id)
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageCacheStore.markMessageDeleted("public", deletedData.message_id)
|
MessageRepository.markPublicMessageDeleted(deletedData.message_id)
|
||||||
MessageCacheStore.replacePublicMessages(_state.messages)
|
MessageCacheStore.replacePublicMessages(_state.messages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,4 +399,6 @@ class PublicChatPanel(
|
|||||||
override fun showCallButton(): Boolean = false
|
override fun showCallButton(): Boolean = false
|
||||||
|
|
||||||
override fun getTypingHandler(): TypingHandler = typingHandler
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
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]).
|
* 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 cachedPanelKey: String? = null
|
||||||
private var cachedDisplayTitle: String? = null
|
private var cachedDisplayTitle: String? = null
|
||||||
private var cachedUserId: Int? = 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() {
|
private fun ensureScope() {
|
||||||
if (!supervisorJob.isActive) {
|
if (!supervisorJob.isActive) {
|
||||||
@@ -36,10 +45,16 @@ object PublicChatPanelCache {
|
|||||||
*/
|
*/
|
||||||
fun getOrCreateGeneralChat(displayTitle: String, currentUserId: Int?): PublicChatPanel {
|
fun getOrCreateGeneralChat(displayTitle: String, currentUserId: Int?): PublicChatPanel {
|
||||||
ensureScope()
|
ensureScope()
|
||||||
|
val instanceId = CacheContext.activeInstanceId.value.trim()
|
||||||
|
if (instanceId.isNotEmpty() && cachedInstanceId.isNotEmpty() && cachedInstanceId != instanceId) {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
if (instanceId.isNotEmpty()) cachedInstanceId = instanceId
|
||||||
if (
|
if (
|
||||||
panel != null &&
|
panel != null &&
|
||||||
cachedPanelKey == GeneralPublicPanelKey &&
|
cachedPanelKey == GeneralPublicPanelKey &&
|
||||||
cachedUserId == currentUserId
|
cachedUserId == currentUserId &&
|
||||||
|
(instanceId.isEmpty() || cachedInstanceId == instanceId)
|
||||||
) {
|
) {
|
||||||
if (cachedDisplayTitle != displayTitle) {
|
if (cachedDisplayTitle != displayTitle) {
|
||||||
cachedDisplayTitle = displayTitle
|
cachedDisplayTitle = displayTitle
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import androidx.compose.animation.SharedTransitionScope
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.db.MessageRepository
|
||||||
|
import ru.fromchat.core.cache.CacheContext
|
||||||
import ru.fromchat.ui.isPublicChatVisible
|
import ru.fromchat.ui.isPublicChatVisible
|
||||||
import ru.fromchat.*
|
import ru.fromchat.*
|
||||||
|
|
||||||
@@ -26,12 +30,21 @@ fun PublicChatScreen(
|
|||||||
PublicChatPanelCache.getOrCreateGeneralChat(publicChatTitle, currentUserId)
|
PublicChatPanelCache.getOrCreateGeneralChat(publicChatTitle, currentUserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(panel) {
|
LaunchedEffect(panel) {
|
||||||
if (panel.getState().messages.isEmpty()) {
|
if (panel.getState().messages.isEmpty()) {
|
||||||
panel.loadMessages()
|
panel.loadMessages()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(panel, activeInstanceId) {
|
||||||
|
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||||
|
MessageRepository.observePublicMessages().collect { rows ->
|
||||||
|
panel.syncMessagesFromDatabase(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track visibility for notifications
|
// Track visibility for notifications
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
isPublicChatVisible = true
|
isPublicChatVisible = true
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import androidx.compose.animation.AnimatedVisibilityScope
|
|||||||
import androidx.compose.animation.SharedTransitionScope
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import ru.fromchat.core.cache.CacheContext
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import ru.fromchat.ui.HapticFeedbackEvent
|
import ru.fromchat.ui.HapticFeedbackEvent
|
||||||
@@ -39,8 +41,8 @@ fun DmChatRoute(
|
|||||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||||
val panel = remember(otherUserId, scope) { DmPanelCache.getOrCreate(otherUserId, scope) }
|
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
||||||
|
|
||||||
@@ -66,8 +68,8 @@ fun DmProfileRoute(
|
|||||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||||
val panel = remember(otherUserId, scope) { DmPanelCache.getOrCreate(otherUserId, scope) }
|
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
||||||
val stateSnapshot = panel.getState()
|
val stateSnapshot = panel.getState()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
@@ -15,10 +16,19 @@ import kotlinx.serialization.json.jsonObject
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.DmEnvelope
|
import ru.fromchat.api.DmEnvelope
|
||||||
|
import ru.fromchat.api.DmDeletedData
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.api.sortMessagesForChatDisplay
|
||||||
import ru.fromchat.api.ProfileCache
|
import ru.fromchat.api.ProfileCache
|
||||||
import ru.fromchat.api.WebSocketMessage
|
import ru.fromchat.api.WebSocketMessage
|
||||||
import ru.fromchat.api.db.MessageCacheStore
|
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.api.visibleDisplayName
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
import ru.fromchat.core.config.Config
|
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.DecryptedImageCache
|
||||||
import ru.fromchat.ui.chat.DmTypingHandler
|
import ru.fromchat.ui.chat.DmTypingHandler
|
||||||
import ru.fromchat.ui.chat.TypingHandler
|
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(
|
class DmPanel(
|
||||||
private val otherUserId: Int,
|
private val otherUserId: Int,
|
||||||
@@ -74,6 +89,22 @@ class DmPanel(
|
|||||||
updateState { it.copy(typingUsers = users) }
|
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) {
|
coroutineScope.launch(Dispatchers.Default) {
|
||||||
runCatching {
|
runCatching {
|
||||||
ApiClient.getProfileById(otherUserId)
|
ApiClient.getProfileById(otherUserId)
|
||||||
@@ -109,11 +140,15 @@ class DmPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) {
|
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,
|
recipientId = otherUserId,
|
||||||
plaintext = content,
|
plaintext = content,
|
||||||
clientMessageId = clientMessageId,
|
clientMessageId = cid,
|
||||||
replyToId = replyToId
|
replyToId = replyToId,
|
||||||
|
optimisticMessage = optimistic,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,11 +163,15 @@ class DmPanel(
|
|||||||
|
|
||||||
override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) {
|
override suspend fun onOptimisticMessageConfirmed(clientMessageId: String, confirmed: Message) {
|
||||||
MessageCacheStore.confirmDmMessage(otherUserId, clientMessageId, confirmed)
|
MessageCacheStore.confirmDmMessage(otherUserId, clientMessageId, confirmed)
|
||||||
|
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(clientMessageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadMessages() {
|
override suspend fun loadMessages() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
|
||||||
|
ru.fromchat.core.cache.CacheContext.requireActiveInstanceId(),
|
||||||
|
)
|
||||||
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
|
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
|
||||||
if (cached.isNotEmpty()) {
|
if (cached.isNotEmpty()) {
|
||||||
clearMessages()
|
clearMessages()
|
||||||
@@ -142,6 +181,7 @@ class DmPanel(
|
|||||||
val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) }
|
val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) }
|
||||||
if (historyResult.isSuccess) {
|
if (historyResult.isSuccess) {
|
||||||
val response = historyResult.getOrNull() ?: return
|
val response = historyResult.getOrNull() ?: return
|
||||||
|
val optimisticSnapshot = snapshotPendingOptimisticMessages()
|
||||||
clearMessages()
|
clearMessages()
|
||||||
val decryptedForLog = mutableListOf<Pair<Int, String>>()
|
val decryptedForLog = mutableListOf<Pair<Int, String>>()
|
||||||
val messages = response.messages.map { envelope ->
|
val messages = response.messages.map { envelope ->
|
||||||
@@ -160,10 +200,18 @@ class DmPanel(
|
|||||||
} else msg
|
} else msg
|
||||||
}
|
}
|
||||||
addMessages(messagesWithReplies)
|
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)
|
setHasMoreMessages(false)
|
||||||
|
|
||||||
// Persist the most recent DM messages for offline use.
|
// Persist the most recent DM messages for offline use.
|
||||||
MessageCacheStore.replaceDmMessages(otherUserId, messagesWithReplies)
|
val mergedForCache = _state.messages
|
||||||
|
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache)
|
||||||
} else {
|
} else {
|
||||||
val error = historyResult.exceptionOrNull()
|
val error = historyResult.exceptionOrNull()
|
||||||
Logger.e("DmPanel", "Failed to load DM history: ${error?.message}", error)
|
Logger.e("DmPanel", "Failed to load DM history: ${error?.message}", error)
|
||||||
@@ -186,6 +234,7 @@ class DmPanel(
|
|||||||
when (message.type) {
|
when (message.type) {
|
||||||
"dmNew" -> message.data?.let { processEnvelope(it) }
|
"dmNew" -> message.data?.let { processEnvelope(it) }
|
||||||
"dmEdited" -> message.data?.let { processEditedEnvelope(it) }
|
"dmEdited" -> message.data?.let { processEditedEnvelope(it) }
|
||||||
|
"dmDeleted" -> message.data?.let { processDeletedEnvelope(it) }
|
||||||
"dmTyping" -> message.data?.let { data ->
|
"dmTyping" -> message.data?.let { data ->
|
||||||
val obj = data.jsonObject
|
val obj = data.jsonObject
|
||||||
val userId = obj["userId"]?.jsonPrimitive?.content?.toIntOrNull()
|
val userId = obj["userId"]?.jsonPrimitive?.content?.toIntOrNull()
|
||||||
@@ -205,6 +254,7 @@ class DmPanel(
|
|||||||
when (type) {
|
when (type) {
|
||||||
"dmNew" -> obj["data"]?.let { processEnvelope(it) }
|
"dmNew" -> obj["data"]?.let { processEnvelope(it) }
|
||||||
"dmEdited" -> obj["data"]?.let { processEditedEnvelope(it) }
|
"dmEdited" -> obj["data"]?.let { processEditedEnvelope(it) }
|
||||||
|
"dmDeleted" -> obj["data"]?.let { processDeletedEnvelope(it) }
|
||||||
"dmTyping" -> obj["data"]?.let { data ->
|
"dmTyping" -> obj["data"]?.let { data ->
|
||||||
val dataObj = data.jsonObject
|
val dataObj = data.jsonObject
|
||||||
val userId = dataObj["userId"]?.jsonPrimitive?.content?.toIntOrNull()
|
val userId = dataObj["userId"]?.jsonPrimitive?.content?.toIntOrNull()
|
||||||
@@ -230,10 +280,26 @@ class DmPanel(
|
|||||||
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
|
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
dmEnvelopeMutex.withLock {
|
dmEnvelopeMutex.withLock {
|
||||||
val alreadyExists = _state.messages.any { it.id == envelope.id }
|
val cid = envelope.clientMessageId?.trim().orEmpty()
|
||||||
val outcome = decryptDmEnvelopeForUi(envelope)
|
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) {
|
if (envelope.senderId == currentUserId) {
|
||||||
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
||||||
@@ -245,7 +311,6 @@ class DmPanel(
|
|||||||
updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
|
updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist updated DM thread to cache
|
|
||||||
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
|
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,45 +319,70 @@ class DmPanel(
|
|||||||
private fun mergeConfirmedOwnMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean) {
|
private fun mergeConfirmedOwnMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean) {
|
||||||
val confirmed = createMessage(envelope, plaintext, isContentCorrupted)
|
val confirmed = createMessage(envelope, plaintext, isContentCorrupted)
|
||||||
val hasAttachments = !envelope.files.isNullOrEmpty()
|
val hasAttachments = !envelope.files.isNullOrEmpty()
|
||||||
|
val cid = envelope.clientMessageId?.trim().orEmpty()
|
||||||
updateState { currentState ->
|
val stateSourceBeforeMerge = _state.messages.firstOrNull { message ->
|
||||||
val existingRealIndex = currentState.messages.indexOfFirst { it.id == envelope.id }
|
|
||||||
val byClientIdIndex = currentState.messages.indexOfFirst { message ->
|
|
||||||
message.id < 0 &&
|
|
||||||
message.user_id == currentUserId &&
|
message.user_id == currentUserId &&
|
||||||
envelope.clientMessageId != null &&
|
cid.isNotEmpty() &&
|
||||||
(message.client_message_id == envelope.clientMessageId || message.uploadJobId == envelope.clientMessageId)
|
(message.client_message_id == cid || message.uploadJobId == cid)
|
||||||
}
|
}
|
||||||
val exactOptimisticIndex = currentState.messages.indexOfFirst { message ->
|
val localUri = stateSourceBeforeMerge?.pendingFileUri
|
||||||
message.user_id == currentUserId &&
|
val dmFile = envelope.files?.firstOrNull()
|
||||||
message.pendingFileUri != null &&
|
val isImageAttachment = dmFile?.let { isImageFilename(it.name) } == true
|
||||||
envelope.clientMessageId != null &&
|
val aspect = imageAspectRatioForMessage(
|
||||||
(message.client_message_id == envelope.clientMessageId || message.uploadJobId == envelope.clientMessageId)
|
fileAspectRatios = confirmed.fileAspectRatios,
|
||||||
}
|
fileDimensions = confirmed.fileDimensions ?: stateSourceBeforeMerge?.fileDimensions,
|
||||||
val optimisticIndex = when {
|
pendingFileAspectRatio = stateSourceBeforeMerge?.pendingFileAspectRatio,
|
||||||
byClientIdIndex >= 0 -> byClientIdIndex
|
fileIndex = 0,
|
||||||
exactOptimisticIndex >= 0 -> exactOptimisticIndex
|
confirmed = true,
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 localPreviewUri = resolveLocalPreviewUri(
|
||||||
|
confirmed.copy(
|
||||||
|
client_message_id = cid.ifEmpty { confirmed.client_message_id },
|
||||||
|
pendingFileUri = localUri ?: confirmed.pendingFileUri,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
val newMessages = when {
|
||||||
optimisticIndex >= 0 -> {
|
optimisticIndex >= 0 -> {
|
||||||
currentState.messages.mapIndexedNotNull { index, message ->
|
currentState.messages.mapIndexedNotNull { index, message ->
|
||||||
@@ -303,16 +393,21 @@ class DmPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
existingRealIndex >= 0 -> {
|
existingRealIndex >= 0 -> {
|
||||||
currentState.messages.mapIndexed { index, message ->
|
currentState.messages.mapIndexed { index, message ->
|
||||||
if (index == existingRealIndex) merged else message
|
if (index == existingRealIndex) merged else message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> currentState.messages + merged
|
else -> currentState.messages + merged
|
||||||
}
|
}
|
||||||
currentState.copy(messages = newMessages)
|
currentState.copy(messages = dedupeMessagesByClientId(newMessages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cid.isNotEmpty()) {
|
||||||
|
MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence)
|
||||||
|
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,15 +417,22 @@ class DmPanel(
|
|||||||
}.getOrNull() ?: return
|
}.getOrNull() ?: return
|
||||||
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
|
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
|
val previous = _state.messages.find { it.id == envelope.id }
|
||||||
|
val filesChanged = previous?.files != envelope.files
|
||||||
|
if (filesChanged) {
|
||||||
DecryptedImageCache.invalidateForMessage(envelope.id)
|
DecryptedImageCache.invalidateForMessage(envelope.id)
|
||||||
|
envelope.clientMessageId?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
DecryptedImageCache.invalidateForClientMessage(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
val outcome = decryptDmEnvelopeForUi(envelope)
|
val outcome = decryptDmEnvelopeForUi(envelope)
|
||||||
val dec = parseDecryptedContent(outcome.plaintext)
|
val dec = parseDmMessageContent(outcome.plaintext)
|
||||||
updateMessage(envelope.id) {
|
updateMessage(envelope.id) {
|
||||||
it.copy(
|
it.copy(
|
||||||
content = dec.text,
|
content = dec.text,
|
||||||
is_edited = true,
|
is_edited = true,
|
||||||
fileThumbnails = dec.thumbnails ?: it.fileThumbnails,
|
fileThumbnails = dec.fileThumbnails ?: it.fileThumbnails,
|
||||||
fileAspectRatios = dec.aspectRatios ?: it.fileAspectRatios,
|
fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios,
|
||||||
fileSizes = dec.fileSizes ?: it.fileSizes,
|
fileSizes = dec.fileSizes ?: it.fileSizes,
|
||||||
fileDimensions = dec.fileDimensions ?: it.fileDimensions,
|
fileDimensions = dec.fileDimensions ?: it.fileDimensions,
|
||||||
isContentCorrupted = outcome.isCorrupted
|
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 {
|
private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message {
|
||||||
val dec = parseDecryptedContent(plaintext)
|
val dec = parseDmMessageContent(plaintext)
|
||||||
val username = if (envelope.senderId == currentUserId) {
|
val username = if (envelope.senderId == currentUserId) {
|
||||||
"You"
|
"You"
|
||||||
} else {
|
} else {
|
||||||
@@ -399,8 +466,8 @@ class DmPanel(
|
|||||||
reactions = null,
|
reactions = null,
|
||||||
files = envelope.files,
|
files = envelope.files,
|
||||||
dmEnvelope = envelope,
|
dmEnvelope = envelope,
|
||||||
fileThumbnails = dec.thumbnails,
|
fileThumbnails = dec.fileThumbnails,
|
||||||
fileAspectRatios = dec.aspectRatios,
|
fileAspectRatios = dec.fileAspectRatios,
|
||||||
fileSizes = dec.fileSizes,
|
fileSizes = dec.fileSizes,
|
||||||
fileDimensions = dec.fileDimensions,
|
fileDimensions = dec.fileDimensions,
|
||||||
isContentCorrupted = isContentCorrupted
|
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
|
override fun showCallButton(): Boolean = Config.callsEnabled
|
||||||
|
|
||||||
@@ -427,4 +530,6 @@ class DmPanel(
|
|||||||
|
|
||||||
override val showUsernamesInMessages: Boolean
|
override val showUsernamesInMessages: Boolean
|
||||||
get() = false
|
get() = false
|
||||||
|
|
||||||
|
override fun outboxConversationId(): String = conversationIdForDm(otherUserId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,50 @@
|
|||||||
package ru.fromchat.ui.dm
|
package ru.fromchat.ui.dm
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.core.cache.CacheContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache of DM panels by otherUserId so that when navigating back from profile
|
* Cache of DM panels by otherUserId so that when navigating back from profile
|
||||||
* we reuse the same panel and don't reload messages.
|
* 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 {
|
object DmPanelCache {
|
||||||
private val panels = mutableMapOf<Int, DmPanel>()
|
private var supervisorJob = SupervisorJob()
|
||||||
|
private var panelScope: CoroutineScope = CoroutineScope(supervisorJob + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
fun getOrCreate(
|
private val panels = mutableMapOf<Int, DmPanel>()
|
||||||
otherUserId: Int,
|
private var cachedInstanceId: String = ""
|
||||||
scope: CoroutineScope
|
|
||||||
): DmPanel {
|
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) {
|
return panels.getOrPut(otherUserId) {
|
||||||
DmPanel(
|
DmPanel(
|
||||||
otherUserId = otherUserId,
|
otherUserId = otherUserId,
|
||||||
coroutineScope = scope,
|
coroutineScope = panelScope,
|
||||||
currentUserId = ApiClient.user?.id
|
currentUserId = ApiClient.user?.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -30,5 +57,6 @@ object DmPanelCache {
|
|||||||
fun clearAll() {
|
fun clearAll() {
|
||||||
panels.values.forEach { it.destroy() }
|
panels.values.forEach { it.destroy() }
|
||||||
panels.clear()
|
panels.clear()
|
||||||
|
supervisorJob.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ import androidx.compose.animation.SharedTransitionScope
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import ru.fromchat.api.ApiClient
|
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
|
import ru.fromchat.ui.chat.ChatScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -24,13 +32,33 @@ fun DmScreen(
|
|||||||
sharedAvatarKey: Any? = null
|
sharedAvatarKey: Any? = null
|
||||||
) {
|
) {
|
||||||
val currentUserId = ApiClient.user?.id
|
val currentUserId = ApiClient.user?.id
|
||||||
|
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||||
|
val otherUserId = panel.getState().profileUserId
|
||||||
|
|
||||||
LaunchedEffect(panel) {
|
LaunchedEffect(panel, otherUserId) {
|
||||||
if (panel.getState().messages.isEmpty()) {
|
if (otherUserId != null && otherUserId > 0) {
|
||||||
panel.loadMessages()
|
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(
|
ChatScreen(
|
||||||
panel = panel,
|
panel = panel,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ import ru.fromchat.api.UserStatus
|
|||||||
import ru.fromchat.api.UserStatusStore
|
import ru.fromchat.api.UserStatusStore
|
||||||
import ru.fromchat.api.db.CachedConversation
|
import ru.fromchat.api.db.CachedConversation
|
||||||
import ru.fromchat.api.db.MessageCacheStore
|
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.app_name
|
||||||
import ru.fromchat.chat_last_mesaage
|
import ru.fromchat.chat_last_mesaage
|
||||||
import ru.fromchat.net.NetworkConnectivity
|
import ru.fromchat.net.NetworkConnectivity
|
||||||
@@ -172,25 +175,25 @@ fun ChatsTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
val serverConfig by Config.serverConfig.collectAsState()
|
||||||
// Load cached DM conversations first for instant offline display.
|
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||||
runCatching {
|
|
||||||
dmConversations = MessageCacheStore.loadCachedDmConversations()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
LaunchedEffect(serverConfig, activeInstanceId) {
|
||||||
|
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||||
runCatching {
|
runCatching {
|
||||||
val last = MessageCacheStore.loadRecentPublicMessages(1).lastOrNull()
|
dmConversations = MessageRepository.loadCachedDmConversations()
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val last = MessageRepository.loadRecentPublicMessages(1).lastOrNull()
|
||||||
publicLastMessagePreview = last?.content?.trim()?.takeIf { it.isNotEmpty() }
|
publicLastMessagePreview = last?.content?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then refresh from network and update cache + state.
|
|
||||||
runCatching {
|
runCatching {
|
||||||
ApiClient.getDmConversations()
|
ApiClient.getDmConversations()
|
||||||
}.onSuccess { conversations ->
|
}.onSuccess { conversations ->
|
||||||
runCatching {
|
runCatching {
|
||||||
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
|
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
|
||||||
MessageCacheStore.replaceDmConversations(conversations)
|
MessageRepository.replaceDmConversations(conversations)
|
||||||
dmConversations = MessageCacheStore.loadCachedDmConversations()
|
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.PhoneAndroid
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Settings
|
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.Storage
|
||||||
import androidx.compose.material.icons.filled.TabletAndroid
|
import androidx.compose.material.icons.filled.TabletAndroid
|
||||||
import androidx.compose.material.icons.filled.VerifiedUser
|
import androidx.compose.material.icons.filled.VerifiedUser
|
||||||
@@ -126,7 +127,15 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.about
|
import ru.fromchat.about
|
||||||
import ru.fromchat.back
|
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.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.ui.imeScrollWithKeyboard
|
||||||
import ru.fromchat.api.DeviceSessionInfo
|
import ru.fromchat.api.DeviceSessionInfo
|
||||||
import ru.fromchat.ui.main.settings.SettingsSecurityPredictiveBackHandler
|
import ru.fromchat.ui.main.settings.SettingsSecurityPredictiveBackHandler
|
||||||
@@ -473,8 +482,14 @@ fun SettingsAppearanceScreen(onBack: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun SettingsServerToolsScreen(onBack: () -> Unit, outerNav: NavController) {
|
fun SettingsServerToolsScreen(onBack: () -> Unit, outerNav: NavController) {
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
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(
|
Scaffold(
|
||||||
|
snackbarHost = { FromChatSnackbarHost(snackbarHostState) },
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
@@ -511,11 +526,57 @@ fun SettingsServerToolsScreen(onBack: () -> Unit, outerNav: NavController) {
|
|||||||
headline = stringResource(Res.string.debug_tools),
|
headline = stringResource(Res.string.debug_tools),
|
||||||
supportingText = stringResource(Res.string.debug_tools_d),
|
supportingText = stringResource(Res.string.debug_tools_d),
|
||||||
onClick = { outerNav.navigate("debug") },
|
onClick = { outerNav.navigate("debug") },
|
||||||
|
divider = true,
|
||||||
|
dividerColor = settingsSurfaceCutDividerColor(),
|
||||||
|
dividerThickness = SettingsSurfaceCutDividerThickness,
|
||||||
leadingContent = { SettingsListLeadingIcon(Icons.Filled.BugReport) }
|
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)
|
@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_defaults
|
||||||
import ru.fromchat.server_config_snackbar_ok_api_calls_bad
|
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
|
||||||
|
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.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_config_title
|
||||||
import ru.fromchat.server_ip_hint
|
import ru.fromchat.server_ip_hint
|
||||||
import ru.fromchat.server_ip_label
|
import ru.fromchat.server_ip_label
|
||||||
@@ -435,6 +442,8 @@ fun ServerConfigScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var busy by remember { mutableStateOf(false) }
|
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) }
|
var showResetDialog by remember { mutableStateOf(false) }
|
||||||
val actionHazeState = rememberHazeState()
|
val actionHazeState = rememberHazeState()
|
||||||
val serverConfigListState = rememberLazyListState()
|
val serverConfigListState = rememberLazyListState()
|
||||||
@@ -445,6 +454,9 @@ fun ServerConfigScreen() {
|
|||||||
val strPortError = stringResource(Res.string.server_config_port_error)
|
val strPortError = stringResource(Res.string.server_config_port_error)
|
||||||
val strSnackbarApiFail = stringResource(Res.string.server_config_snackbar_api_fail)
|
val strSnackbarApiFail = stringResource(Res.string.server_config_snackbar_api_fail)
|
||||||
val strSnackbarOkApiCallsBad = stringResource(Res.string.server_config_snackbar_ok_api_calls_bad)
|
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 strResetConfirmTitle = stringResource(Res.string.server_config_action_reset_confirm_title)
|
||||||
val strResetConfirmBody = stringResource(Res.string.server_config_action_reset_confirm_body)
|
val strResetConfirmBody = stringResource(Res.string.server_config_action_reset_confirm_body)
|
||||||
|
|
||||||
@@ -473,50 +485,48 @@ fun ServerConfigScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val verifyServer: () -> Unit = {
|
fun buildTentativeConfig(): ServerConfigData? {
|
||||||
scope.launch {
|
|
||||||
// Do not block actual checks on snackbar dismissal.
|
|
||||||
launch { snackbarHostState.showSnackbar("Checking...") }
|
|
||||||
val host = serverIp.trim()
|
val host = serverIp.trim()
|
||||||
if (host.isEmpty() || !isValidIpOrHostname(host)) {
|
if (host.isEmpty() || !isValidIpOrHostname(host)) return null
|
||||||
snackbarHostState.showSnackbar(strHostError)
|
if (apiPortText.isNotEmpty() && !isValidPortNumber(apiPortText)) return null
|
||||||
return@launch
|
if (callsPortText.isNotEmpty() && !isValidPortNumber(callsPortText)) return null
|
||||||
}
|
return ServerConfigData(
|
||||||
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,
|
serverIp = host,
|
||||||
apiPort = apiPort,
|
apiPort = resolvedApiPort(apiPortText),
|
||||||
callsPort = calls,
|
callsPort = effectiveCallsPort(callsPortText),
|
||||||
httpsEnabled = httpsEnabled,
|
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
|
|
||||||
}
|
}
|
||||||
}.getOrElse { strSnackbarApiFail }
|
|
||||||
|
|
||||||
|
val verifyServer: () -> Unit = {
|
||||||
|
scope.launch {
|
||||||
|
launch { snackbarHostState.showSnackbar(strChecking) }
|
||||||
|
val tentative = buildTentativeConfig()
|
||||||
|
if (tentative == null) {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
if (serverIp.isNotEmpty() && !isValidIpOrHostname(serverIp.trim())) {
|
||||||
|
strHostError
|
||||||
|
} else {
|
||||||
|
strPortError
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ServerProbeResult.Unsupported -> strUnsupportedInstance
|
||||||
|
ServerProbeResult.Timeout -> strSnackbarTimeout
|
||||||
|
ServerProbeResult.Unreachable -> strSnackbarApiFail
|
||||||
|
}
|
||||||
snackbarHostState.showSnackbar(msg)
|
snackbarHostState.showSnackbar(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -605,56 +615,40 @@ fun ServerConfigScreen() {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
busy = true
|
busy = true
|
||||||
try {
|
try {
|
||||||
val tentative = ServerConfigData(
|
val tentative = buildTentativeConfig() ?: return@launch
|
||||||
serverIp = serverIp.trim(),
|
val probe = if (
|
||||||
apiPort = apiPortEffective,
|
lastProbedConfig == tentative && lastProbe != null
|
||||||
callsPort = callsPortParsed,
|
) {
|
||||||
httpsEnabled = httpsEnabled,
|
lastProbe!!
|
||||||
)
|
} else {
|
||||||
val tentativeApi = apiBaseUrlFor(tentative)
|
probeServer(tentative).also {
|
||||||
val newId = runCatching {
|
lastProbe = it
|
||||||
ApiClient.fetchServerInstanceId(tentativeApi)
|
lastProbedConfig = tentative
|
||||||
}.getOrNull()?.trim().orEmpty()
|
|
||||||
if (newId.isEmpty()) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
reloginClearingSession(navController)
|
|
||||||
}
|
}
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
|
when (probe) {
|
||||||
|
ServerProbeResult.Unsupported -> {
|
||||||
|
snackbarHostState.showSnackbar(strUnsupportedInstance)
|
||||||
|
}
|
||||||
|
ServerProbeResult.Timeout -> {
|
||||||
|
snackbarHostState.showSnackbar(strSnackbarTimeout)
|
||||||
|
}
|
||||||
|
ServerProbeResult.Unreachable -> {
|
||||||
|
snackbarHostState.showSnackbar(strSnackbarApiFail)
|
||||||
|
}
|
||||||
|
is ServerProbeResult.Supported -> {
|
||||||
|
Settings.lastKnownServerInstanceId = probe.instanceId
|
||||||
val bearer = ApiClient.token?.trim().orEmpty()
|
val bearer = ApiClient.token?.trim().orEmpty()
|
||||||
if (bearer.isEmpty()) {
|
applyServerAndNavigate(
|
||||||
val callsOk = probeCallsReachable(tentative)
|
probe = probe,
|
||||||
Config.updateServerConfig(tentative.copy(callsEnabled = callsOk))
|
config = tentative,
|
||||||
Settings.lastKnownServerInstanceId = newId
|
bearer = bearer,
|
||||||
WebSocketManager.disconnect()
|
onNavigateLogin = {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
navController.navigateAndWipeBackStack("login")
|
navController.navigateAndWipeBackStack("login")
|
||||||
}
|
}
|
||||||
return@launch
|
},
|
||||||
}
|
onNavigateChat = {
|
||||||
|
|
||||||
val persisted = Settings.lastKnownServerInstanceId.trim()
|
|
||||||
if (persisted.isNotEmpty() && !newId.equals(persisted, ignoreCase = true)) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
reloginClearingSession(navController)
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val authOk = ApiClient.checkAuthAt(tentativeApi, bearer)
|
|
||||||
if (!authOk) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
reloginClearingSession(navController)
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val callsOk = probeCallsReachable(tentative)
|
|
||||||
Config.updateServerConfig(tentative.copy(callsEnabled = callsOk))
|
|
||||||
Settings.lastKnownServerInstanceId = newId
|
|
||||||
WebSocketManager.disconnect()
|
|
||||||
WebSocketManager.connect(forceRestart = true)
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (!navController.popBackStack()) {
|
if (!navController.popBackStack()) {
|
||||||
navController.navigate("chat") {
|
navController.navigate("chat") {
|
||||||
@@ -662,6 +656,15 @@ fun ServerConfigScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onLogoutOldHost = {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
reloginClearingSession(navController)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy = false
|
busy = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
CREATE TABLE conversation (
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
instanceId TEXT NOT NULL,
|
||||||
type TEXT NOT NULL, -- "public" or "dm"
|
id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
otherUserId INTEGER,
|
otherUserId INTEGER,
|
||||||
displayName TEXT,
|
displayName TEXT,
|
||||||
lastMessageId INTEGER,
|
lastMessageId INTEGER,
|
||||||
lastMessagePreview TEXT,
|
lastMessagePreview TEXT,
|
||||||
unreadCount INTEGER NOT NULL DEFAULT 0,
|
unreadCount INTEGER NOT NULL DEFAULT 0,
|
||||||
updatedAt TEXT
|
updatedAt TEXT,
|
||||||
|
PRIMARY KEY (instanceId, id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE message (
|
CREATE TABLE message (
|
||||||
|
instanceId TEXT NOT NULL,
|
||||||
id INTEGER NOT NULL,
|
id INTEGER NOT NULL,
|
||||||
conversationId TEXT NOT NULL,
|
conversationId TEXT NOT NULL,
|
||||||
userId INTEGER NOT NULL,
|
userId INTEGER NOT NULL,
|
||||||
@@ -20,55 +35,120 @@ CREATE TABLE message (
|
|||||||
replyToId INTEGER,
|
replyToId INTEGER,
|
||||||
clientMessageId TEXT,
|
clientMessageId TEXT,
|
||||||
deletedFlag INTEGER NOT NULL DEFAULT 0,
|
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 (
|
CREATE TABLE attachment (
|
||||||
|
instanceId TEXT NOT NULL,
|
||||||
id INTEGER NOT NULL,
|
id INTEGER NOT NULL,
|
||||||
messageId INTEGER NOT NULL,
|
messageId INTEGER NOT NULL,
|
||||||
conversationId TEXT NOT NULL,
|
conversationId TEXT NOT NULL,
|
||||||
remotePath TEXT,
|
remotePath TEXT,
|
||||||
localPath TEXT,
|
localPath TEXT,
|
||||||
status TEXT NOT NULL, -- "PENDING", "DOWNLOADING", "READY"
|
status TEXT NOT NULL,
|
||||||
blurhash TEXT,
|
blurhash TEXT,
|
||||||
aspectRatio REAL,
|
aspectRatio REAL,
|
||||||
size INTEGER,
|
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:
|
selectMessagesByConversation:
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM message
|
FROM message
|
||||||
WHERE conversationId = ? AND deletedFlag = 0
|
WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0
|
||||||
ORDER BY timestamp ASC;
|
ORDER BY timestamp ASC;
|
||||||
|
|
||||||
-- Last N rows for a conversation (newest first in SQL; reverse in Kotlin for chronological UI).
|
|
||||||
selectRecentMessagesByConversation:
|
selectRecentMessagesByConversation:
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM message
|
FROM message
|
||||||
WHERE conversationId = ? AND deletedFlag = 0
|
WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT :limit;
|
LIMIT :limit;
|
||||||
|
|
||||||
-- Optimistic rows only (avoid scanning the full conversation on replace).
|
|
||||||
selectPendingMessagesByConversation:
|
selectPendingMessagesByConversation:
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM message
|
FROM message
|
||||||
WHERE conversationId = ? AND deletedFlag = 0 AND id < 0
|
WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 AND id < 0
|
||||||
ORDER BY timestamp ASC;
|
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:
|
deleteMessagesForConversation:
|
||||||
DELETE FROM message
|
DELETE FROM message
|
||||||
WHERE conversationId = ?;
|
WHERE instanceId = ? AND conversationId = ?;
|
||||||
|
|
||||||
deleteMessageByClientMessageId:
|
deleteMessageByClientMessageId:
|
||||||
DELETE FROM message
|
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:
|
upsertMessage:
|
||||||
INSERT OR REPLACE INTO message(
|
INSERT OR REPLACE INTO message(
|
||||||
|
instanceId,
|
||||||
id,
|
id,
|
||||||
conversationId,
|
conversationId,
|
||||||
userId,
|
userId,
|
||||||
@@ -78,21 +158,49 @@ INSERT OR REPLACE INTO message(
|
|||||||
isEdited,
|
isEdited,
|
||||||
replyToId,
|
replyToId,
|
||||||
clientMessageId,
|
clientMessageId,
|
||||||
deletedFlag
|
deletedFlag,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
sendStatus
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
|
|
||||||
markMessageDeleted:
|
markMessageDeleted:
|
||||||
UPDATE message
|
UPDATE message
|
||||||
SET deletedFlag = 1
|
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 *
|
SELECT *
|
||||||
FROM conversation
|
FROM conversation
|
||||||
|
WHERE instanceId = ?
|
||||||
ORDER BY updatedAt DESC;
|
ORDER BY updatedAt DESC;
|
||||||
|
|
||||||
upsertConversation:
|
upsertConversation:
|
||||||
INSERT OR REPLACE INTO conversation(
|
INSERT OR REPLACE INTO conversation(
|
||||||
|
instanceId,
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
otherUserId,
|
otherUserId,
|
||||||
@@ -101,5 +209,43 @@ INSERT OR REPLACE INTO conversation(
|
|||||||
lastMessagePreview,
|
lastMessagePreview,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
updatedAt
|
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 (?, ?, ?, ?, ?, ?, ?, ?);
|
) 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) {
|
install(HttpTimeout) {
|
||||||
requestTimeoutMillis = 30000
|
requestTimeoutMillis = 30_000
|
||||||
connectTimeoutMillis = 30000
|
connectTimeoutMillis = 5_000
|
||||||
|
socketTimeoutMillis = 30_000
|
||||||
}
|
}
|
||||||
|
|
||||||
block(this)
|
block(this)
|
||||||
|
|||||||
@@ -1,45 +1,17 @@
|
|||||||
package ru.fromchat.api
|
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.flow.SharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import ru.fromchat.api.outbox.scheduleOutboxProcessing
|
||||||
|
import ru.fromchat.core.cache.CacheContext
|
||||||
private val iosUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
||||||
|
|
||||||
actual object AttachmentUploadQueue {
|
actual object AttachmentUploadQueue {
|
||||||
private val _progressFlow = MutableSharedFlow<AttachmentUploadProgress>(extraBufferCapacity = 64)
|
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadNotifier.progressFlow
|
||||||
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = _progressFlow
|
|
||||||
|
|
||||||
actual fun enqueue(job: AttachmentUploadJob) {
|
actual fun enqueue(job: AttachmentUploadJob) {
|
||||||
_progressFlow.tryEmit(AttachmentUploadProgress.Pending(job.jobId, job.filename))
|
scheduleOutboxProcessing(CacheContext.activeInstanceId.value.trim())
|
||||||
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"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun cancel(jobId: String) {
|
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.db.SqlDriver
|
||||||
import app.cash.sqldelight.driver.native.NativeSqliteDriver
|
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
|
import ru.fromchat.db.MessageDatabase
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
actual fun provideMessageDatabaseDriver(): SqlDriver {
|
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(
|
return NativeSqliteDriver(
|
||||||
schema = MessageDatabase.Schema,
|
schema = MessageDatabase.Schema,
|
||||||
name = "message_database.db"
|
name = "$dir/message_database.db",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.fromchat.api.db
|
||||||
|
|
||||||
|
import platform.Foundation.NSRecursiveLock
|
||||||
|
|
||||||
|
private val lock = NSRecursiveLock()
|
||||||
|
|
||||||
|
internal actual fun <T> withMessageDatabaseLock(block: () -> T): T {
|
||||||
|
lock.lock()
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} finally {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.fromchat.api.outbox
|
||||||
|
|
||||||
|
import kotlinx.coroutines.MainScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** iOS: drain outbox on enqueue and when the app returns to foreground (parity with WorkManager). */
|
||||||
|
actual fun scheduleOutboxProcessing(instanceId: String) {
|
||||||
|
if (instanceId.trim().isEmpty()) return
|
||||||
|
MainScope().launch {
|
||||||
|
OutgoingMessageCoordinator.drainActiveInstanceOutbox()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun cancelOutboxProcessing(instanceId: String) = Unit
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import platform.Foundation.NSCachesDirectory
|
||||||
|
import platform.Foundation.NSFileManager
|
||||||
|
import platform.Foundation.NSUserDomainMask
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual suspend fun wipeFromChatCacheDirectory() {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val url = NSFileManager.defaultManager.URLForDirectory(
|
||||||
|
directory = NSCachesDirectory,
|
||||||
|
inDomain = NSUserDomainMask,
|
||||||
|
appropriateForURL = null,
|
||||||
|
create = false,
|
||||||
|
error = null,
|
||||||
|
) ?: return@withContext
|
||||||
|
val path = url.path + "/fromchat"
|
||||||
|
NSFileManager.defaultManager.removeItemAtPath(path, error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.addressOf
|
||||||
|
import kotlinx.cinterop.usePinned
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import platform.Foundation.NSCachesDirectory
|
||||||
|
import platform.Foundation.NSData
|
||||||
|
import platform.Foundation.NSFileManager
|
||||||
|
import platform.Foundation.NSUserDomainMask
|
||||||
|
import platform.Foundation.create
|
||||||
|
import platform.Foundation.writeToFile
|
||||||
|
import ru.fromchat.api.db.MessageDatabaseProvider
|
||||||
|
|
||||||
|
private const val GENERATION_FILE = ".generation"
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun fromChatRoot(): String {
|
||||||
|
val url = NSFileManager.defaultManager.URLForDirectory(
|
||||||
|
directory = NSCachesDirectory,
|
||||||
|
inDomain = NSUserDomainMask,
|
||||||
|
appropriateForURL = null,
|
||||||
|
create = true,
|
||||||
|
error = null,
|
||||||
|
) ?: return ""
|
||||||
|
return url.path + "/fromchat"
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun writeGenerationMarker(path: String) {
|
||||||
|
val bytes = "1\n".encodeToByteArray()
|
||||||
|
bytes.usePinned { pinned ->
|
||||||
|
val data = NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
|
||||||
|
data?.writeToFile(path, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual suspend fun ensureFromChatCacheGeneration() {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val root = fromChatRoot()
|
||||||
|
if (root.isEmpty()) return@withContext
|
||||||
|
val marker = "$root/$GENERATION_FILE"
|
||||||
|
if (NSFileManager.defaultManager.fileExistsAtPath(marker)) return@withContext
|
||||||
|
MessageDatabaseProvider.closeAndReset()
|
||||||
|
wipeFromChatCacheDirectory()
|
||||||
|
NSFileManager.defaultManager.createDirectoryAtPath(root, true, null, null)
|
||||||
|
writeGenerationMarker(marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual suspend fun writeFromChatCacheGeneration() {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val root = fromChatRoot()
|
||||||
|
if (root.isEmpty()) return@withContext
|
||||||
|
NSFileManager.defaultManager.createDirectoryAtPath(root, true, null, null)
|
||||||
|
writeGenerationMarker("$root/$GENERATION_FILE")
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package ru.fromchat.core.cache
|
||||||
|
|
||||||
|
import kotlinx.cinterop.BetaInteropApi
|
||||||
|
import kotlinx.cinterop.ByteVar
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.addressOf
|
||||||
|
import kotlinx.cinterop.get
|
||||||
|
import kotlinx.cinterop.reinterpret
|
||||||
|
import kotlinx.cinterop.usePinned
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import platform.Foundation.NSCachesDirectory
|
||||||
|
import platform.Foundation.NSData
|
||||||
|
import platform.Foundation.NSFileManager
|
||||||
|
import platform.Foundation.NSURL
|
||||||
|
import platform.Foundation.NSUserDomainMask
|
||||||
|
import platform.Foundation.create
|
||||||
|
import platform.Foundation.dataWithContentsOfURL
|
||||||
|
import platform.Foundation.writeToFile
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun uploadDir(instanceId: String): String {
|
||||||
|
val url = NSFileManager.defaultManager.URLForDirectory(
|
||||||
|
directory = NSCachesDirectory,
|
||||||
|
inDomain = NSUserDomainMask,
|
||||||
|
appropriateForURL = null,
|
||||||
|
create = true,
|
||||||
|
error = null,
|
||||||
|
) ?: return ""
|
||||||
|
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
|
val path = url.path + "/fromchat/instances/$safe/uploads"
|
||||||
|
NSFileManager.defaultManager.createDirectoryAtPath(path, true, null, null)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun blobPath(instanceId: String, clientMessageId: String): String {
|
||||||
|
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
|
return "${uploadDir(instanceId)}/$safeId.enc"
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun cipherPath(instanceId: String, clientMessageId: String): String {
|
||||||
|
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
|
return "${uploadDir(instanceId)}/$safeId.cipher.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sourcePath(instanceId: String, clientMessageId: String): String {
|
||||||
|
val safeId = clientMessageId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
|
return "${uploadDir(instanceId)}/$safeId.source"
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
private fun readBytesAtPath(path: String): ByteArray? {
|
||||||
|
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return null
|
||||||
|
val data = NSData.create(contentsOfFile = path) ?: return null
|
||||||
|
val length = data.length.toInt()
|
||||||
|
if (length == 0) return null
|
||||||
|
val bytesPtr = data.bytes ?: return null
|
||||||
|
val bytePtr = bytesPtr.reinterpret<ByteVar>()
|
||||||
|
return ByteArray(length) { i -> bytePtr[i] }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
private fun writeBytesAtPath(path: String, bytes: ByteArray) {
|
||||||
|
bytes.usePinned { pinned ->
|
||||||
|
val data = NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong())
|
||||||
|
data?.writeToFile(path, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual suspend fun stageOutboundFileForUpload(
|
||||||
|
instanceId: String,
|
||||||
|
clientMessageId: String,
|
||||||
|
sourceUri: String,
|
||||||
|
): StagedOutboundFile = withContext(Dispatchers.Default) {
|
||||||
|
val dest = sourcePath(instanceId, clientMessageId)
|
||||||
|
if (sourceUri != dest) {
|
||||||
|
val existing = readBytesAtPath(dest)
|
||||||
|
if (existing == null || existing.isEmpty()) {
|
||||||
|
val bytes = readOutboundFileBytes(sourceUri)
|
||||||
|
if (bytes.isEmpty()) {
|
||||||
|
throw OutboundFileUnavailableException("File is empty or unavailable")
|
||||||
|
}
|
||||||
|
writeBytesAtPath(dest, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val size = readBytesAtPath(dest)?.size?.toLong() ?: 0L
|
||||||
|
StagedOutboundFile(uri = dest, sizeBytes = size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual suspend fun readOutboundFileBytes(fileUri: String): ByteArray =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val url = NSURL.URLWithString(fileUri) ?: throw OutboundFileUnavailableException("Invalid file URI")
|
||||||
|
val data = NSData.dataWithContentsOfURL(url)
|
||||||
|
?: throw OutboundFileUnavailableException("Failed to read file from URI")
|
||||||
|
val length = data.length.toInt()
|
||||||
|
val bytesPtr = data.bytes ?: error("Empty file")
|
||||||
|
val bytePtr = bytesPtr.reinterpret<ByteVar>()
|
||||||
|
ByteArray(length) { i -> bytePtr[i] }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual suspend fun saveEncryptedUploadBlob(instanceId: String, clientMessageId: String, bytes: ByteArray) {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
writeBytesAtPath(blobPath(instanceId, clientMessageId), bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual suspend fun loadEncryptedUploadBlob(instanceId: String, clientMessageId: String): ByteArray? =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
readBytesAtPath(blobPath(instanceId, clientMessageId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual suspend fun saveUploadTransportCipherJson(instanceId: String, clientMessageId: String, json: String) {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
writeBytesAtPath(cipherPath(instanceId, clientMessageId), json.encodeToByteArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual suspend fun loadUploadTransportCipherJson(instanceId: String, clientMessageId: String): String? =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
readBytesAtPath(cipherPath(instanceId, clientMessageId))?.decodeToString()?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual suspend fun clearUploadArtifacts(instanceId: String, clientMessageId: String) {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
NSFileManager.defaultManager.removeItemAtPath(blobPath(instanceId, clientMessageId), null)
|
||||||
|
NSFileManager.defaultManager.removeItemAtPath(cipherPath(instanceId, clientMessageId), null)
|
||||||
|
NSFileManager.defaultManager.removeItemAtPath(sourcePath(instanceId, clientMessageId), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual suspend fun clearUploadSecretsOnly(instanceId: String, clientMessageId: String) {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
NSFileManager.defaultManager.removeItemAtPath(blobPath(instanceId, clientMessageId), null)
|
||||||
|
NSFileManager.defaultManager.removeItemAtPath(cipherPath(instanceId, clientMessageId), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ actual object DmCrypto {
|
|||||||
actual suspend fun decryptEnvelope(
|
actual suspend fun decryptEnvelope(
|
||||||
ivB64: String,
|
ivB64: String,
|
||||||
ciphertextB64: String,
|
ciphertextB64: String,
|
||||||
mek: ByteArray
|
mek: ByteArray,
|
||||||
) = withContext(Dispatchers.Default) {
|
) = withContext(Dispatchers.Default) {
|
||||||
val key = mek.require("MEK must be 32 bytes") {
|
val key = mek.require("MEK must be 32 bytes") {
|
||||||
it.size == AES_KEY_SIZE
|
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 {
|
private suspend fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray {
|
||||||
require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes for GCM" }
|
require(iv.size == GCM_IV_SIZE) { "IV must be 12 bytes for GCM" }
|
||||||
require(key.size == AES_KEY_SIZE) { "Key must be 32 bytes" }
|
require(key.size == AES_KEY_SIZE) { "Key must be 32 bytes" }
|
||||||
|
|||||||
@@ -17,3 +17,5 @@ actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun getImageAspectRatio(uri: String): Float? = null
|
actual suspend fun getImageAspectRatio(uri: String): Float? = null
|
||||||
|
|
||||||
|
actual suspend fun getImageDimensions(uri: String): Pair<Int, Int>? = null
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class)
|
||||||
|
|
||||||
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
|
import kotlinx.cinterop.ByteVar
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.get
|
||||||
|
import kotlinx.cinterop.reinterpret
|
||||||
|
import kotlinx.cinterop.useContents
|
||||||
|
import org.jetbrains.skia.Bitmap
|
||||||
|
import org.jetbrains.skia.Image
|
||||||
|
import org.jetbrains.skia.ImageInfo
|
||||||
|
import org.jetbrains.skia.SamplingMode
|
||||||
|
import platform.CoreGraphics.CGRectMake
|
||||||
|
import platform.Foundation.NSData
|
||||||
|
import platform.Foundation.create
|
||||||
|
import platform.UIKit.UIGraphicsBeginImageContextWithOptions
|
||||||
|
import platform.UIKit.UIGraphicsEndImageContext
|
||||||
|
import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext
|
||||||
|
import platform.UIKit.UIImage
|
||||||
|
import platform.UIKit.UIImageOrientation
|
||||||
|
import platform.UIKit.UIImagePNGRepresentation
|
||||||
|
|
||||||
|
actual object PlatformDecodedBitmapCache {
|
||||||
|
private val cache = mutableMapOf<String, ImageBitmap>()
|
||||||
|
|
||||||
|
actual fun get(key: String): ImageBitmap? = cache[key]
|
||||||
|
|
||||||
|
actual fun put(key: String, bitmap: ImageBitmap) {
|
||||||
|
cache[key] = bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun remove(key: String) {
|
||||||
|
cache.remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun evictPrefix(prefix: String) {
|
||||||
|
val keys = cache.keys.filter { it.startsWith(prefix) }
|
||||||
|
for (key in keys) {
|
||||||
|
cache.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun decodeLocalImageFile(absolutePath: String, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? {
|
||||||
|
val data = NSData.create(contentsOfFile = absolutePath) ?: return null
|
||||||
|
val uiImage = UIImage.imageWithData(data) ?: return null
|
||||||
|
val normalized = normalizeUiImageOrientation(uiImage)
|
||||||
|
val pngData = UIImagePNGRepresentation(normalized) ?: return null
|
||||||
|
return decodeImageBytes(nsDataToByteArray(pngData), reqWidthPx, reqHeightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun decodeImageBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap? =
|
||||||
|
runCatching {
|
||||||
|
val image = Image.makeFromEncoded(bytes) ?: return@runCatching null
|
||||||
|
scaleSkiaImageToFitWithin(image, reqWidthPx, reqHeightPx)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun normalizeUiImageOrientation(image: UIImage): UIImage {
|
||||||
|
if (image.imageOrientation == UIImageOrientation.UIImageOrientationUp) return image
|
||||||
|
val width = image.size.useContents { width }
|
||||||
|
val height = image.size.useContents { height }
|
||||||
|
UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale)
|
||||||
|
image.drawInRect(CGRectMake(0.0, 0.0, width, height))
|
||||||
|
val normalized = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
|
UIGraphicsEndImageContext()
|
||||||
|
return normalized ?: image
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nsDataToByteArray(data: NSData): ByteArray {
|
||||||
|
val length = data.length.toInt()
|
||||||
|
if (length <= 0) return ByteArray(0)
|
||||||
|
val bytesPtr = data.bytes ?: return ByteArray(0)
|
||||||
|
val bytePtr = bytesPtr.reinterpret<ByteVar>()
|
||||||
|
return ByteArray(length) { i -> bytePtr[i] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scaleSkiaImageToFitWithin(image: Image, reqWidthPx: Int, reqHeightPx: Int): ImageBitmap {
|
||||||
|
if (image.width <= reqWidthPx && image.height <= reqHeightPx) return image.toComposeImageBitmap()
|
||||||
|
val scale = minOf(
|
||||||
|
reqWidthPx.toFloat() / image.width.toFloat(),
|
||||||
|
reqHeightPx.toFloat() / image.height.toFloat(),
|
||||||
|
)
|
||||||
|
if (scale >= 1f) return image.toComposeImageBitmap()
|
||||||
|
val dstW = (image.width * scale).toInt().coerceAtLeast(1)
|
||||||
|
val dstH = (image.height * scale).toInt().coerceAtLeast(1)
|
||||||
|
val dst = Bitmap()
|
||||||
|
dst.allocPixels(ImageInfo.makeN32Premul(dstW, dstH))
|
||||||
|
val pixmap = dst.peekPixels() ?: return image.toComposeImageBitmap()
|
||||||
|
image.scalePixels(pixmap, SamplingMode.LINEAR, true)
|
||||||
|
return Image.makeFromBitmap(dst).toComposeImageBitmap()
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ agp = "9.1.1"
|
|||||||
androidx-activityCompose = "1.13.0"
|
androidx-activityCompose = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core-ktx = "1.18.0"
|
androidx-core-ktx = "1.18.0"
|
||||||
|
androidx-exifinterface = "1.3.7"
|
||||||
coilCompose = "3.4.0"
|
coilCompose = "3.4.0"
|
||||||
compose-multiplatform = "1.10.3"
|
compose-multiplatform = "1.10.3"
|
||||||
#noinspection NewerVersionAvailable
|
#noinspection NewerVersionAvailable
|
||||||
@@ -47,6 +48,7 @@ livekitAndroidComposeComponents = "2.3.0"
|
|||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
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-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-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
|
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" }
|
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.pr0gramm3r101.utils
|
||||||
|
|
||||||
|
import android.content.ClipData as AndroidClipData
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.Clipboard
|
||||||
|
|
||||||
|
actual fun Clipboard.toSupport(): SupportClipboardManager {
|
||||||
|
val clipboard = this
|
||||||
|
return object : SupportClipboardManager {
|
||||||
|
private var listener: ((String) -> Unit)? = null
|
||||||
|
|
||||||
|
override suspend fun setText(string: String) {
|
||||||
|
val clipData = AndroidClipData.newPlainText("text", string)
|
||||||
|
clipboard.setClipEntry(ClipEntry(clipData))
|
||||||
|
listener?.invoke(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getText(): String? {
|
||||||
|
val entry = clipboard.getClipEntry() ?: return null
|
||||||
|
return entry.clipData?.getItemAt(0)?.text?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTextListener(listener: (String) -> Unit) {
|
||||||
|
this.listener = listener
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -7,7 +7,9 @@ internal actual fun expectExists(path: String): Boolean =
|
|||||||
File(path).exists()
|
File(path).exists()
|
||||||
|
|
||||||
internal actual fun expectWriteBytes(path: String, bytes: ByteArray) {
|
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) {
|
internal actual fun expectDelete(path: String) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user