diff --git a/app/build.gradle.kts b/app/build.gradle.kts index efe860db6..ccbd4deb9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,12 +10,13 @@ if (file("google-services.json").exists()) { apply(plugin = "com.google.gms.google-services") } -val localProperties = Properties().apply { - val file = rootProject.file("local.properties") - if (file.exists()) { - file.inputStream().use(::load) +val localProperties = + Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) { + file.inputStream().use(::load) + } } -} fun credential(name: String): String? = localProperties.getProperty(name)?.takeIf { it.isNotBlank() } @@ -32,19 +33,25 @@ val unsignedBuild = providers.gradleProperty("unsigned").orNull.equals("true", ignoreCase = true) val startupPrewarm = !providers.gradleProperty("startupPrewarm").orNull.equals("false", ignoreCase = true) val targetAbiProp = providers.gradleProperty("targetAbi").orNull -val selectedAbis = if (!targetAbiProp.isNullOrBlank()) { - listOf(targetAbiProp) -} else { - listOf("armeabi-v7a", "arm64-v8a", "x86_64") -} +val selectedAbis = + if (!targetAbiProp.isNullOrBlank()) { + listOf(targetAbiProp) + } else { + listOf("armeabi-v7a", "arm64-v8a", "x86_64") + } val appVersionCode = 20 val appVersionName = "0.4.3" /** Short git SHA of the checked-out commit, shown next to the build type in settings. */ -val gitCommit: String = providers.exec { - commandLine("git", "rev-parse", "--short", "HEAD") - isIgnoreExitValue = true -}.standardOutput.asText.get().trim().ifEmpty { "unknown" } +val gitCommit: String = + providers + .exec { + commandLine("git", "rev-parse", "--short", "HEAD") + isIgnoreExitValue = true + }.standardOutput.asText + .get() + .trim() + .ifEmpty { "unknown" } android { namespace = "org.monogram" @@ -138,11 +145,12 @@ androidComponents { onVariants { variant -> val buildType = variant.buildType ?: variant.name.substringAfterLast("-") variant.outputs.forEach { output -> - val abi = output.filters - .firstOrNull { it.filterType.name == "ABI" } - ?.identifier - ?: targetAbiProp?.takeIf { it.isNotBlank() } - ?: "universal" + val abi = + output.filters + .firstOrNull { it.filterType.name == "ABI" } + ?.identifier + ?: targetAbiProp?.takeIf { it.isNotBlank() } + ?: "universal" output.outputFileName.set("monogram-$abi-$appVersionName-$buildType.apk") } } @@ -203,6 +211,7 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.media3.exoplayer) + androidTestImplementation(libs.coil.video) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/app/src/androidTest/java/org/monogram/ComposerEditingTest.kt b/app/src/androidTest/java/org/monogram/ComposerEditingTest.kt index 82729c524..c66473cca 100644 --- a/app/src/androidTest/java/org/monogram/ComposerEditingTest.kt +++ b/app/src/androidTest/java/org/monogram/ComposerEditingTest.kt @@ -47,6 +47,7 @@ class ComposerEditingTest { private var scenario: ActivityScenario? = null private lateinit var root: RootComponent + private val historyRequests = mutableListOf>() @After fun cleanup() { @@ -54,6 +55,37 @@ class ComposerEditingTest { UiStateTestActivity.content = {} } + @Test + fun chatRowOpensRenderedHistory() { + val messages = (1..80).map { id -> + Message( + org.monogram.core.models.MessageId(PeerId(1), id), + null, + "Loading fixture $id", + id.toLong(), + outgoing = true + ) + } + launch(messages) + val startedAt = android.os.SystemClock.elapsedRealtime() + compose.onNodeWithText("Chat 001").performClick() + compose.waitUntil(10_000) { + (root.stack.value.active.instance as? RootComponent.Child.Dialog) + ?.component?.state?.value?.messages?.isNotEmpty() == true + } + compose.onNodeWithText("Loading fixture 80", substring = true).assertIsDisplayed() + compose.runOnIdle { + assertEquals(1, historyRequests.count { it.first == "getHistory" }) + assertEquals(emptyList>(), historyRequests.filter { + it.first == "getHistoryPageBackground" || it.second < 80 + }) + } + android.util.Log.i( + "LoadingUiTiming", + "operation=chat_click_history_render ms=${android.os.SystemClock.elapsedRealtime() - startedAt}" + ) + } + @Test fun selectedMessagesOpenUnifiedRecipientPicker() { val messages = listOf(1, 2).map { id -> @@ -187,9 +219,13 @@ class ComposerEditingTest { "toString" -> "ComposerEditingFakeClient" "libraryVersion" -> "test" "updates", "sessionLost" -> emptyFlow() + "proxyConnectionStatus" -> org.monogram.network.bridge.session.ProxyConnectionStatus.Disabled "getChats" -> Outcome.Ok(chats) "getFolders" -> Outcome.Ok(listOf(Folder(2, "Work", chatIds = chats.map { it.id }))) - "getHistory", "getHistoryPage" -> Outcome.Ok(messages) + "getHistory", "getHistoryPage", "getHistoryPageBackground" -> { + historyRequests += method.name to (args!![1] as Int) + Outcome.Ok(messages) + } "getPinnedMessages" -> Outcome.Ok(emptyList()) "getProfile" -> { val id = args!![0] as PeerId diff --git a/app/src/androidTest/java/org/monogram/NetcodePerfHarnessTest.kt b/app/src/androidTest/java/org/monogram/NetcodePerfHarnessTest.kt index 3d1a2dccb..07f4119c8 100644 --- a/app/src/androidTest/java/org/monogram/NetcodePerfHarnessTest.kt +++ b/app/src/androidTest/java/org/monogram/NetcodePerfHarnessTest.kt @@ -3,10 +3,6 @@ package org.monogram import android.os.SystemClock import android.util.Log import androidx.test.platform.app.InstrumentationRegistry -import java.io.File -import java.net.HttpURLConnection -import java.net.URL -import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -19,11 +15,15 @@ import org.junit.Assume.assumeTrue import org.junit.Test import org.monogram.core.common.Outcome import org.monogram.core.common.PerfLog -import org.monogram.core.ui.DownloadSettings import org.monogram.core.models.Chat import org.monogram.core.models.Message import org.monogram.core.models.PeerId +import org.monogram.core.ui.DownloadSettings import org.monogram.network.bridge.BridgedMtprotoClient +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicLong /** * Live netcode measurement harness: read-only calls against the signed-in account @@ -40,316 +40,489 @@ class NetcodePerfHarnessTest { private val tag = PerfLog.TAG @Test - fun measureLiveNetcode() = runBlocking { - val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as MonogramApp - assertTrue("startup services", app.awaitReadyBlocking()) - val client = app.client - val repository = app.mediaRepository - val samples = mutableListOf() - - fun emit(line: String) { - Log.i(tag, line) - samples += line + fun measureConnectBreakdown() = + runBlocking { + val app = + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as MonogramApp + assertTrue("startup services", app.awaitReadyBlocking()) + uniffi.monogram_mtproto.perfSetEnabled(true) + uniffi.monogram_mtproto.perfSnapshot(true) + try { + var outcome: Outcome? = null + val elapsed = + measure { outcome = withTimeoutOrNull(60_000) { app.client.connect() } } + val snapshot = org.json.JSONObject(uniffi.monogram_mtproto.perfSnapshot(true)) + val names = setOf("connect", "pfs.promote_main", "pfs.upload_lane", "pfs.read_lane") + val ops = snapshot.getJSONArray("ops") + for (index in 0 until ops.length()) { + val op = ops.getJSONObject(index) + val name = op.getString("op") + if (name in names) { + Log.i( + tag, + "BREAKDOWN op=$name count=${op.getLong("count")} sum_us=${op.getLong("sum_us")} max_us=${ + op.getLong( + "max_us" + ) + }", + ) + } + } + Log.i(tag, "BREAKDOWN connect_ms=$elapsed ok=${outcome is Outcome.Ok}") + assertTrue("connect failed or timed out", outcome is Outcome.Ok) + } finally { + uniffi.monogram_mtproto.perfSetEnabled(false) + } } - emit("HARNESS start perf_enabled=${PerfLog.isEnabled()}") - - var connected: Outcome? = null - val connectMs = measure { connected = withTimeoutOrNull(60_000) { client.connect() } } - assertOk("connect", connected) - emit("HARNESS connect_cold ms=$connectMs result=ok") - var warmConnect: Outcome? = null - val warmConnectMs = measure { warmConnect = withTimeoutOrNull(10_000) { client.connect() } } - assertOk("warm connect", warmConnect) - emit("HARNESS connect_warm ms=$warmConnectMs result=ok") - - val chatsOutcome = withTimeoutOrNull(60_000) { client.getChats() } - assertOk("get chats", chatsOutcome) - val chats = (chatsOutcome as Outcome.Ok).value - emit("HARNESS chats count=${chats.size}") - - val media = collectMedia(client, chats, 6) - emit("HARNESS media candidates=${media.size} sizes=${media.take(8).map { it.fileSize ?: 0 }}") - assumeTrue("no downloadable media on this account", media.isNotEmpty()) - - // First-page history latency (dialog open) and thumbnail retrieval latency. - val firstChat = media.first().id.chatId - var historyOutcome: Outcome>? = null - val historyMs = measure { historyOutcome = withTimeoutOrNull(60_000) { client.getHistory(firstChat, 50) } } - assertOk("first history page", historyOutcome) - emit("HARNESS history_first_page chat_ms=$historyMs") - - val thumbTarget = media.firstOrNull { it.thumbCacheKey != null } - ?: run { - assumeTrue("no downloadable thumbnail on this account", false) - return@runBlocking + @Test + fun measureLiveNetcode() = + runBlocking { + val app = + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as MonogramApp + assertTrue("startup services", app.awaitReadyBlocking()) + val client = app.client + val repository = app.mediaRepository + val samples = mutableListOf() + + fun emit(line: String) { + Log.i(tag, line) + samples += line + } + + emit("HARNESS start perf_enabled=${PerfLog.isEnabled()}") + + var connected: Outcome? = null + val connectMs = measure { connected = withTimeoutOrNull(60_000) { client.connect() } } + assertOk("connect", connected) + emit("HARNESS connect_cold ms=$connectMs result=ok") + var warmConnect: Outcome? = null + val warmConnectMs = + measure { warmConnect = withTimeoutOrNull(10_000) { client.connect() } } + assertOk("warm connect", warmConnect) + emit("HARNESS connect_warm ms=$warmConnectMs result=ok") + + val chatsOutcome = withTimeoutOrNull(60_000) { client.getChats() } + assertOk("get chats", chatsOutcome) + val chats = (chatsOutcome as Outcome.Ok).value + emit("HARNESS chats count=${chats.size}") + + val media = collectMedia(client, chats, 6) + emit( + "HARNESS media candidates=${media.size} sizes=${ + media.take(8).map { it.fileSize ?: 0 } + }" + ) + assumeTrue("no downloadable media on this account", media.isNotEmpty()) + + // First-page history latency (dialog open) and thumbnail retrieval latency. + val firstChat = media.first().id.chatId + var historyOutcome: Outcome>? = null + val historyMs = measure { + historyOutcome = withTimeoutOrNull(60_000) { client.getHistory(firstChat, 50) } } - thumbTarget.thumbCacheKey?.let { repository.removeCachedFile(it) } - var thumbOutcome: Outcome? = null - val thumbMs = measure { thumbOutcome = withTimeoutOrNull(60_000) { repository.ensureLocalMessageThumb(thumbTarget) } } - assertOk("first thumbnail retrieval", thumbOutcome) - emit("HARNESS thumb_fetch ms=$thumbMs") - var thumbCachedOutcome: Outcome? = null - val thumbCachedMs = measure { thumbCachedOutcome = withTimeoutOrNull(10_000) { repository.ensureLocalMessageThumb(thumbTarget) } } - assertOk("cached thumbnail retrieval", thumbCachedOutcome) - emit("HARNESS thumb_cache_hit ms=$thumbCachedMs") - - // Single-file throughput on the real user path (queue -> native -> publish). - val target = media.maxByOrNull { it.fileSize ?: 0L } ?: media.first() - val targetKey = target.mediaCacheKey - if (targetKey != null) repository.removeCachedFile(targetKey) - val size = target.fileSize ?: 0L - var singleOutcome: Outcome? = null - val singleMs = measure { singleOutcome = withTimeoutOrNull(180_000) { repository.ensureLocalMessageMedia(target) } } - assertOk("single download", singleOutcome) - emit("HARNESS download_single bytes=$size ms=$singleMs mib_s=${mibPerSecond(size, singleMs)}") - - // Same file again, cache cleared: separates first-use lane/transport cost from - // steady-state throughput on the user path. - if (targetKey != null) repository.removeCachedFile(targetKey) - var warmOutcome: Outcome? = null - val warmMs = measure { warmOutcome = withTimeoutOrNull(180_000) { repository.ensureLocalMessageMedia(target) } } - assertOk("warm single download", warmOutcome) - emit("HARNESS download_single_warm bytes=$size ms=$warmMs mib_s=${mibPerSecond(size, warmMs)}") - - // Warm-lane round trip: same 512 KiB chunk, fetched repeatedly (repo cache bypassed). - // If this is close to the per-part time above, the part loop is latency-bound. - val warmDir = File(app.cacheDir, "perf-warm").apply { mkdirs() } - val warmLatencies = (1..3).mapNotNull { attempt -> - val warmFile = File(warmDir, "warm-$attempt.part") - var chunkOutcome: Outcome? = null - val latency = measure { - chunkOutcome = withTimeoutOrNull(60_000) { - client.downloadMessageMediaChunk(target.id.chatId, target.id.id, warmFile.absolutePath, 0L) + assertOk("first history page", historyOutcome) + emit("HARNESS history_first_page chat_ms=$historyMs") + + val thumbTarget = + media.firstOrNull { it.thumbCacheKey != null } + ?: run { + assumeTrue("no downloadable thumbnail on this account", false) + return@runBlocking + } + thumbTarget.thumbCacheKey?.let { repository.removeCachedFile(it) } + var thumbOutcome: Outcome? = null + val thumbMs = measure { + thumbOutcome = + withTimeoutOrNull(60_000) { repository.ensureLocalMessageThumb(thumbTarget) } + } + assertOk("first thumbnail retrieval", thumbOutcome) + emit("HARNESS thumb_fetch ms=$thumbMs") + var thumbCachedOutcome: Outcome? = null + val thumbCachedMs = + measure { + thumbCachedOutcome = + withTimeoutOrNull(10_000) { repository.ensureLocalMessageThumb(thumbTarget) } } + assertOk("cached thumbnail retrieval", thumbCachedOutcome) + emit("HARNESS thumb_cache_hit ms=$thumbCachedMs") + + // Single-file throughput on the real user path (queue -> native -> publish). + val target = media.maxByOrNull { it.fileSize ?: 0L } ?: media.first() + val targetKey = target.mediaCacheKey + if (targetKey != null) repository.removeCachedFile(targetKey) + val size = target.fileSize ?: 0L + var singleOutcome: Outcome? = null + val singleMs = measure { + singleOutcome = + withTimeoutOrNull(180_000) { repository.ensureLocalMessageMedia(target) } } - assertOk("warm chunk $attempt", chunkOutcome) - warmFile.delete() - latency - } - assertTrue("expected three successful warm chunks", warmLatencies.size == 3) - emit("HARNESS rtt_warm_chunk ms=${warmLatencies.joinToString(",")} min=${warmLatencies.minOrNull() ?: 0}") - - // Parallel files: does a second and fourth concurrent download raise throughput? - val others = media.filter { it.mediaCacheKey != null && it.id != target.id }.take(5) - for (width in listOf(2, 4)) { - val batch = others.take(width - 1) - if (batch.size < width - 1) continue - val jobs = batch + target - jobs.forEach { it.mediaCacheKey?.let { key -> repository.removeCachedFile(key) } } - val bytes = jobs.sumOf { it.fileSize ?: 0L } - val parallelMs = measure { - coroutineScope { - jobs.map { message -> async(Dispatchers.IO) { repository.ensureLocalMessageMedia(message) } } - .forEachIndexed { index, job -> assertOk("parallel download $index", job.await()) } + assertOk("single download", singleOutcome) + emit( + "HARNESS download_single bytes=$size ms=$singleMs mib_s=${ + mibPerSecond( + size, + singleMs + ) + }" + ) + + // Same file again, cache cleared: separates first-use lane/transport cost from + // steady-state throughput on the user path. + if (targetKey != null) repository.removeCachedFile(targetKey) + var warmOutcome: Outcome? = null + val warmMs = measure { + warmOutcome = + withTimeoutOrNull(180_000) { repository.ensureLocalMessageMedia(target) } + } + assertOk("warm single download", warmOutcome) + emit( + "HARNESS download_single_warm bytes=$size ms=$warmMs mib_s=${ + mibPerSecond( + size, + warmMs + ) + }" + ) + + // Warm-lane round trip: same 512 KiB chunk, fetched repeatedly (repo cache bypassed). + // If this is close to the per-part time above, the part loop is latency-bound. + val warmDir = File(app.cacheDir, "perf-warm").apply { mkdirs() } + val warmLatencies = + (1..3).mapNotNull { attempt -> + val warmFile = File(warmDir, "warm-$attempt.part") + var chunkOutcome: Outcome? = null + val latency = + measure { + chunkOutcome = + withTimeoutOrNull(60_000) { + client.downloadMessageMediaChunk( + target.id.chatId, + target.id.id, + warmFile.absolutePath, + 0L + ) + } + } + assertOk("warm chunk $attempt", chunkOutcome) + warmFile.delete() + latency } + assertTrue("expected three successful warm chunks", warmLatencies.size == 3) + emit("HARNESS rtt_warm_chunk ms=${warmLatencies.joinToString(",")} min=${warmLatencies.minOrNull() ?: 0}") + + // Parallel files: does a second and fourth concurrent download raise throughput? + val others = media.filter { it.mediaCacheKey != null && it.id != target.id }.take(5) + for (width in listOf(2, 4)) { + val batch = others.take(width - 1) + if (batch.size < width - 1) continue + val jobs = batch + target + jobs.forEach { it.mediaCacheKey?.let { key -> repository.removeCachedFile(key) } } + val bytes = jobs.sumOf { it.fileSize ?: 0L } + val parallelMs = + measure { + coroutineScope { + jobs + .map { message -> + async(Dispatchers.IO) { + repository.ensureLocalMessageMedia( + message + ) + } + } + .forEachIndexed { index, job -> + assertOk( + "parallel download $index", + job.await() + ) + } + } + } + emit( + "HARNESS download_parallel width=$width bytes=$bytes ms=$parallelMs mib_s=${ + mibPerSecond( + bytes, + parallelMs + ) + }" + ) } - emit("HARNESS download_parallel width=$width bytes=$bytes ms=$parallelMs mib_s=${mibPerSecond(bytes, parallelMs)}") - } - // Two large files together: the honest test of whether lanes scale. - val large = media.sortedByDescending { it.fileSize ?: 0L }.take(2) - if (large.size == 2 && (large[1].fileSize ?: 0L) > 1024L * 1024) { - large.forEach { message -> message.mediaCacheKey?.let { key -> repository.removeCachedFile(key) } } - val largeBytes = large.sumOf { it.fileSize ?: 0L } - val largeMs = measure { - coroutineScope { - large.map { message -> async(Dispatchers.IO) { repository.ensureLocalMessageMedia(message) } } - .forEachIndexed { index, job -> assertOk("large parallel download $index", job.await()) } + // Two large files together: the honest test of whether lanes scale. + val large = media.sortedByDescending { it.fileSize ?: 0L }.take(2) + if (large.size == 2 && (large[1].fileSize ?: 0L) > 1024L * 1024) { + large.forEach { message -> + message.mediaCacheKey?.let { key -> + repository.removeCachedFile( + key + ) + } } + val largeBytes = large.sumOf { it.fileSize ?: 0L } + val largeMs = + measure { + coroutineScope { + large + .map { message -> + async(Dispatchers.IO) { + repository.ensureLocalMessageMedia( + message + ) + } + } + .forEachIndexed { index, job -> + assertOk( + "large parallel download $index", + job.await() + ) + } + } + } + emit( + "HARNESS download_two_large bytes=$largeBytes ms=$largeMs mib_s=${ + mibPerSecond( + largeBytes, + largeMs + ) + }" + ) } - emit("HARNESS download_two_large bytes=$largeBytes ms=$largeMs mib_s=${mibPerSecond(largeBytes, largeMs)}") - } - // Overlap proof: a history RPC must not serialize a media download. - val overlapMessage = others.lastOrNull { it.mediaCacheKey != null } - if (overlapMessage != null) { - overlapMessage.mediaCacheKey?.let { repository.removeCachedFile(it) } - val downloadStart = SystemClock.elapsedRealtime() - val downloadEnd = AtomicLong(0L) - val download = async(Dispatchers.IO) { - val result = withTimeoutOrNull(180_000) { repository.ensureLocalMessageMedia(overlapMessage) } - downloadEnd.set(SystemClock.elapsedRealtime()) - result + // Overlap proof: a history RPC must not serialize a media download. + val overlapMessage = others.lastOrNull { it.mediaCacheKey != null } + if (overlapMessage != null) { + overlapMessage.mediaCacheKey?.let { repository.removeCachedFile(it) } + val downloadStart = SystemClock.elapsedRealtime() + val downloadEnd = AtomicLong(0L) + val download = + async(Dispatchers.IO) { + val result = withTimeoutOrNull(180_000) { + repository.ensureLocalMessageMedia(overlapMessage) + } + downloadEnd.set(SystemClock.elapsedRealtime()) + result + } + delay(150) + val historyStart = SystemClock.elapsedRealtime() + val history = + withTimeoutOrNull(60_000) { client.getHistory(overlapMessage.id.chatId, 50) } + val historyEnd = SystemClock.elapsedRealtime() + assertOk("overlap history", history) + assertOk("overlap download", download.await()) + val completedAt = downloadEnd.get() + val overlap = historyStart < completedAt && downloadStart < historyEnd + assertTrue("history and media operations must overlap", overlap) + emit( + "HARNESS overlap history=[${historyStart - downloadStart},${historyEnd - downloadStart}] " + + "download=[0,${completedAt - downloadStart}] overlap=$overlap history_ok=${history is Outcome.Ok}", + ) } - delay(150) - val historyStart = SystemClock.elapsedRealtime() - val history = withTimeoutOrNull(60_000) { client.getHistory(overlapMessage.id.chatId, 50) } - val historyEnd = SystemClock.elapsedRealtime() - assertOk("overlap history", history) - assertOk("overlap download", download.await()) - val completedAt = downloadEnd.get() - val overlap = historyStart < completedAt && downloadStart < historyEnd - assertTrue("history and media operations must overlap", overlap) + + val expected = DownloadSettings.concurrency + client.applyDownloadConcurrency(expected.lanes, expected.parts) emit( - "HARNESS overlap history=[${historyStart - downloadStart},${historyEnd - downloadStart}] " + - "download=[0,${completedAt - downloadStart}] overlap=$overlap history_ok=${history is Outcome.Ok}", + "HARNESS concurrency expected=[${expected.lanes},${expected.parts}] " + + "applied=${client.downloadConcurrency()}", + ) + + // Device ceiling for the same network: one raw HTTP transfer, no Telegram path. + var ceiling = 0L to 0L + val ceilingWallMs = + measure { ceiling = withContext(Dispatchers.IO) { rawHttpThroughput() } } + val ceilingMs = if (ceiling.first > 0) ceiling.first else ceilingWallMs + emit( + "HARNESS http_ceiling bytes=${ceiling.second} ms=$ceilingMs mib_s=${ + mibPerSecond( + ceiling.second, + ceilingMs + ) + }" ) - } - val expected = DownloadSettings.concurrency - client.applyDownloadConcurrency(expected.lanes, expected.parts) - emit( - "HARNESS concurrency expected=[${expected.lanes},${expected.parts}] " + - "applied=${client.downloadConcurrency()}", - ) - - // Device ceiling for the same network: one raw HTTP transfer, no Telegram path. - var ceiling = 0L to 0L - val ceilingWallMs = measure { ceiling = withContext(Dispatchers.IO) { rawHttpThroughput() } } - val ceilingMs = if (ceiling.first > 0) ceiling.first else ceilingWallMs - emit("HARNESS http_ceiling bytes=${ceiling.second} ms=$ceilingMs mib_s=${mibPerSecond(ceiling.second, ceilingMs)}") - - // A single HTTP stream understates the device: measure the aggregate too. - var parallelCeilingBytes = 0L - val parallelCeilingMs = measure { - val results = coroutineScope { - (1..3).map { async(Dispatchers.IO) { rawHttpThroughput() } }.map { it.await() } + // A single HTTP stream understates the device: measure the aggregate too. + var parallelCeilingBytes = 0L + val parallelCeilingMs = + measure { + val results = + coroutineScope { + (1..3).map { async(Dispatchers.IO) { rawHttpThroughput() } } + .map { it.await() } + } + parallelCeilingBytes = results.sumOf { it.second } + } + emit( + "HARNESS http_ceiling_parallel streams=3 bytes=$parallelCeilingBytes ms=$parallelCeilingMs " + + "mib_s=${mibPerSecond(parallelCeilingBytes, parallelCeilingMs)}", + ) + + // Repeat the same file to separate first-use cost from steady state. Pipeline + // depth lives in native (`PIPELINE_PARTS`, swept by `curve-pipeline`). + repeat(3) { round -> + if (targetKey != null) repository.removeCachedFile(targetKey) + var repeatOutcome: Outcome? = null + val millis = measure { + repeatOutcome = + withTimeoutOrNull(240_000) { repository.ensureLocalMessageMedia(target) } + } + assertOk("repeat download ${round + 1}", repeatOutcome) + emit( + "HARNESS repeat round=${round + 1} bytes=$size ms=$millis mib_s=${ + mibPerSecond( + size, + millis + ) + }" + ) } - parallelCeilingBytes = results.sumOf { it.second } - } - emit( - "HARNESS http_ceiling_parallel streams=3 bytes=$parallelCeilingBytes ms=$parallelCeilingMs " + - "mib_s=${mibPerSecond(parallelCeilingBytes, parallelCeilingMs)}", - ) - - // Repeat the same file to separate first-use cost from steady state. Pipeline - // depth lives in native (`PIPELINE_PARTS`, swept by `curve-pipeline`). - repeat(3) { round -> - if (targetKey != null) repository.removeCachedFile(targetKey) - var repeatOutcome: Outcome? = null - val millis = measure { repeatOutcome = withTimeoutOrNull(240_000) { repository.ensureLocalMessageMedia(target) } } - assertOk("repeat download ${round + 1}", repeatOutcome) - emit("HARNESS repeat round=${round + 1} bytes=$size ms=$millis mib_s=${mibPerSecond(size, millis)}") - } - PerfLog.dump("perf:harness") - emit("PERF-SUMMARY ${samples.filter { it.startsWith("HARNESS") }.joinToString(" | ")}") - client.close() - } + PerfLog.dump("perf:harness") + emit("PERF-SUMMARY ${samples.filter { it.startsWith("HARNESS") }.joinToString(" | ")}") + client.close() + } /** * Open-path baseline: history, photo thumb, document, and profile. * Durations, kinds, and sizes only. Does not send, delete, or log message text. */ @Test - fun measureOpenPaths() = runBlocking { - val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as MonogramApp - assertTrue("startup services", app.awaitReadyBlocking()) - val client = app.client - val repository = app.mediaRepository - val samples = mutableListOf() - - fun emit(line: String) { - Log.i(tag, line) - samples += line - } - - emit("HARNESS open start perf_enabled=${PerfLog.isEnabled()}") - - var connected: Outcome? = null - val connectMs = measure { connected = withTimeoutOrNull(60_000) { client.connect() } } - assertOk("connect", connected) - emit("HARNESS connect ms=$connectMs") - - val chatsOutcome = withTimeoutOrNull(60_000) { client.getChats() } - assertOk("get chats", chatsOutcome) - val chats = (chatsOutcome as Outcome.Ok).value - assumeTrue("no chats on this account", chats.isNotEmpty()) - val chatId = chats.first().id - - var roomHistory: List = emptyList() - val roomHistoryMs = measure { roomHistory = app.warmup.messages(chatId, 50) } - emit("HARNESS history_room ms=$roomHistoryMs count=${roomHistory.size}") + fun measureOpenPaths() = + runBlocking { + val app = + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as MonogramApp + assertTrue("startup services", app.awaitReadyBlocking()) + val client = app.client + val repository = app.mediaRepository + val samples = mutableListOf() + + fun emit(line: String) { + Log.i(tag, line) + samples += line + } - var historyOutcome: Outcome>? = null - val historyMs = measure { historyOutcome = withTimeoutOrNull(60_000) { client.getHistory(chatId, 50) } } - assertOk("first history page", historyOutcome) - val history = (historyOutcome as Outcome.Ok).value - emit("HARNESS history_first_page ms=$historyMs count=${history.size}") + emit("HARNESS open start perf_enabled=${PerfLog.isEnabled()}") - var historyRepeat: Outcome>? = null - val historyRepeatMs = measure { - historyRepeat = withTimeoutOrNull(60_000) { client.getHistory(chatId, 50) } - } - assertOk("repeat history page", historyRepeat) - emit("HARNESS history_repeat ms=$historyRepeatMs") + var connected: Outcome? = null + val connectMs = measure { connected = withTimeoutOrNull(60_000) { client.connect() } } + assertOk("connect", connected) + emit("HARNESS connect ms=$connectMs") - val roomProfileHit = measureProfileHit(app, chatId) - emit("HARNESS profile_room ms=${roomProfileHit.first} hit=${roomProfileHit.second}") + val chatsOutcome = withTimeoutOrNull(60_000) { client.getChats() } + assertOk("get chats", chatsOutcome) + val chats = (chatsOutcome as Outcome.Ok).value + assumeTrue("no chats on this account", chats.isNotEmpty()) + val chatId = chats.first().id - var profileOutcome: Outcome<*>? = null - val profileMs = measure { profileOutcome = withTimeoutOrNull(60_000) { client.getProfile(chatId) } } - assertOk("profile", profileOutcome) - emit("HARNESS profile_first ms=$profileMs") + var roomHistory: List = emptyList() + val roomHistoryMs = measure { roomHistory = app.warmup.messages(chatId, 50) } + emit("HARNESS history_room ms=$roomHistoryMs count=${roomHistory.size}") - var profileRepeat: Outcome<*>? = null - val profileRepeatMs = measure { - profileRepeat = withTimeoutOrNull(60_000) { client.getProfile(chatId) } - } - assertOk("repeat profile", profileRepeat) - emit("HARNESS profile_repeat ms=$profileRepeatMs") - - val media = history + collectKinds(client, chats.drop(1), 24) - val kindCounts = media.groupingBy { it.mediaKind ?: "none" }.eachCount() - emit("HARNESS media_kinds ${kindCounts.entries.joinToString(",") { "${it.key}=${it.value}" }}") - val photo = media.firstOrNull { it.mediaKind == "photo" && it.thumbCacheKey != null } - if (photo != null) { - photo.thumbCacheKey?.let { repository.removeCachedFile(it) } - var thumbOutcome: Outcome? = null - val thumbMs = measure { - thumbOutcome = withTimeoutOrNull(60_000) { repository.ensureLocalMessageThumb(photo) } + var historyOutcome: Outcome>? = null + val historyMs = measure { + historyOutcome = withTimeoutOrNull(60_000) { client.getHistory(chatId, 50) } } - assertOk("photo thumb", thumbOutcome) - emit("HARNESS photo_thumb ms=$thumbMs") - var thumbCached: Outcome? = null - val thumbCachedMs = measure { - thumbCached = withTimeoutOrNull(10_000) { repository.ensureLocalMessageThumb(photo) } + assertOk("first history page", historyOutcome) + val history = (historyOutcome as Outcome.Ok).value + emit("HARNESS history_first_page ms=$historyMs count=${history.size}") + + var historyRepeat: Outcome>? = null + val historyRepeatMs = + measure { + historyRepeat = withTimeoutOrNull(60_000) { client.getHistory(chatId, 50) } + } + assertOk("repeat history page", historyRepeat) + emit("HARNESS history_repeat ms=$historyRepeatMs") + + val roomProfileHit = measureProfileHit(app, chatId) + emit("HARNESS profile_room ms=${roomProfileHit.first} hit=${roomProfileHit.second}") + + var profileOutcome: Outcome<*>? = null + val profileMs = + measure { profileOutcome = withTimeoutOrNull(60_000) { client.getProfile(chatId) } } + assertOk("profile", profileOutcome) + emit("HARNESS profile_first ms=$profileMs") + + var profileRepeat: Outcome<*>? = null + val profileRepeatMs = + measure { + profileRepeat = withTimeoutOrNull(60_000) { client.getProfile(chatId) } + } + assertOk("repeat profile", profileRepeat) + emit("HARNESS profile_repeat ms=$profileRepeatMs") + + val media = history + collectKinds(client, chats.drop(1), 24) + val kindCounts = media.groupingBy { it.mediaKind ?: "none" }.eachCount() + emit("HARNESS media_kinds ${kindCounts.entries.joinToString(",") { "${it.key}=${it.value}" }}") + val photo = media.firstOrNull { it.mediaKind == "photo" && it.thumbCacheKey != null } + if (photo != null) { + photo.thumbCacheKey?.let { repository.removeCachedFile(it) } + var thumbOutcome: Outcome? = null + val thumbMs = + measure { + thumbOutcome = + withTimeoutOrNull(60_000) { repository.ensureLocalMessageThumb(photo) } + } + assertOk("photo thumb", thumbOutcome) + emit("HARNESS photo_thumb ms=$thumbMs") + var thumbCached: Outcome? = null + val thumbCachedMs = + measure { + thumbCached = + withTimeoutOrNull(10_000) { repository.ensureLocalMessageThumb(photo) } + } + assertOk("photo thumb cache", thumbCached) + emit("HARNESS photo_cache_hit ms=$thumbCachedMs") + } else { + emit("HARNESS photo_thumb skipped=none") + emit("HARNESS photo_cache_hit skipped=none") } - assertOk("photo thumb cache", thumbCached) - emit("HARNESS photo_cache_hit ms=$thumbCachedMs") - } else { - emit("HARNESS photo_thumb skipped=none") - emit("HARNESS photo_cache_hit skipped=none") - } - val document = media - .filter { - it.mediaKind == "document" && - it.mediaCacheKey != null && - (it.fileSize ?: 0L) in 1..(8L * 1024 * 1024) - } - .minByOrNull { it.fileSize ?: Long.MAX_VALUE } - if (document != null) { - document.mediaCacheKey?.let { repository.removeCachedFile(it) } - val size = document.fileSize ?: 0L - var documentOutcome: Outcome? = null - val documentMs = measure { - documentOutcome = withTimeoutOrNull(120_000) { repository.ensureLocalMessageMedia(document) } - } - assertOk("document", documentOutcome) - emit("HARNESS document_fetch bytes=$size ms=$documentMs") - var documentCached: Outcome? = null - val documentCachedMs = measure { - documentCached = withTimeoutOrNull(10_000) { repository.ensureLocalMessageMedia(document) } + val document = + media + .filter { + it.mediaKind == "document" && + it.mediaCacheKey != null && + (it.fileSize ?: 0L) in 1..(8L * 1024 * 1024) + }.minByOrNull { it.fileSize ?: Long.MAX_VALUE } + if (document != null) { + document.mediaCacheKey?.let { repository.removeCachedFile(it) } + val size = document.fileSize ?: 0L + var documentOutcome: Outcome? = null + val documentMs = + measure { + documentOutcome = + withTimeoutOrNull(120_000) { repository.ensureLocalMessageMedia(document) } + } + assertOk("document", documentOutcome) + emit("HARNESS document_fetch bytes=$size ms=$documentMs") + var documentCached: Outcome? = null + val documentCachedMs = + measure { + documentCached = + withTimeoutOrNull(10_000) { repository.ensureLocalMessageMedia(document) } + } + assertOk("document cache", documentCached) + emit("HARNESS document_cache_hit ms=$documentCachedMs") + } else { + emit("HARNESS document_fetch skipped=none") + emit("HARNESS document_cache_hit skipped=none") } - assertOk("document cache", documentCached) - emit("HARNESS document_cache_hit ms=$documentCachedMs") - } else { - emit("HARNESS document_fetch skipped=none") - emit("HARNESS document_cache_hit skipped=none") - } - emit("PERF-SUMMARY ${samples.filter { it.startsWith("HARNESS") }.joinToString(" | ")}") - } + emit("PERF-SUMMARY ${samples.filter { it.startsWith("HARNESS") }.joinToString(" | ")}") + } - private suspend fun measureProfileHit(app: MonogramApp, chatId: PeerId): Pair { + private suspend fun measureProfileHit( + app: MonogramApp, + chatId: PeerId, + ): Pair { var hit = false val millis = measure { hit = app.sessionStore.readProfile(chatId.value) != null } return millis to hit } - private fun assertOk(label: String, outcome: Outcome<*>?) { + private fun assertOk( + label: String, + outcome: Outcome<*>?, + ) { assertTrue("$label failed or timed out: $outcome", outcome is Outcome.Ok) } @@ -360,12 +533,13 @@ class NetcodePerfHarnessTest { ): List { val found = mutableListOf() for (chat in chats.take(limit)) { - val page = withTimeoutOrNull(30_000) { - when (val outcome = client.getHistory(chat.id, 40)) { - is Outcome.Ok -> outcome.value - is Outcome.Err -> emptyList() - } - } ?: continue + val page = + withTimeoutOrNull(30_000) { + when (val outcome = client.getHistory(chat.id, 40)) { + is Outcome.Ok -> outcome.value + is Outcome.Err -> emptyList() + } + } ?: continue found += page.filter { it.mediaKind != null } if (found.count { it.mediaKind == "document" && it.mediaCacheKey != null } >= 3 && found.any { it.mediaKind == "photo" && it.thumbCacheKey != null } @@ -383,12 +557,13 @@ class NetcodePerfHarnessTest { ): List { val found = mutableListOf() for (chat in chats.take(limit)) { - val history = withTimeoutOrNull(30_000) { - when (val outcome = client.getHistory(chat.id, 50)) { - is Outcome.Ok -> outcome.value - is Outcome.Err -> emptyList() - } - } ?: continue + val history = + withTimeoutOrNull(30_000) { + when (val outcome = client.getHistory(chat.id, 50)) { + is Outcome.Ok -> outcome.value + is Outcome.Err -> emptyList() + } + } ?: continue found += history.filter { it.mediaCacheKey != null && (it.fileSize ?: 0L) in (64L * 1024)..(16L * 1024 * 1024) } if (found.size >= 8) break } @@ -401,7 +576,10 @@ class NetcodePerfHarnessTest { return SystemClock.elapsedRealtime() - start } - private fun mibPerSecond(bytes: Long, millis: Long): String { + private fun mibPerSecond( + bytes: Long, + millis: Long, + ): String { if (bytes <= 0L || millis <= 0L) return "0.00" val seconds = millis / 1000.0 return String.format(java.util.Locale.US, "%.2f", bytes / 1024.0 / 1024.0 / seconds) @@ -410,28 +588,31 @@ class NetcodePerfHarnessTest { /** Raw transfer over the emulator's network stack; skipped when unreachable. */ private fun rawHttpThroughput(): Pair { val bytes = 8 * 1024 * 1024 - val endpoints = listOf( - "https://speed.cloudflare.com/__down?bytes=$bytes", - "https://proof.ovh.net/files/10Mb.dat", - ) + val endpoints = + listOf( + "https://speed.cloudflare.com/__down?bytes=$bytes", + "https://proof.ovh.net/files/10Mb.dat", + ) for (endpoint in endpoints) { val start = SystemClock.elapsedRealtime() var read = 0L - val connection = runCatching { URL(endpoint).openConnection() as HttpURLConnection }.getOrNull() - ?: continue - val ok = runCatching { - connection.connectTimeout = 15_000 - connection.readTimeout = 30_000 - connection.instanceFollowRedirects = true - connection.inputStream.use { input -> - val buffer = ByteArray(256 * 1024) - while (read < bytes) { - val count = input.read(buffer) - if (count <= 0) break - read += count + val connection = + runCatching { URL(endpoint).openConnection() as HttpURLConnection }.getOrNull() + ?: continue + val ok = + runCatching { + connection.connectTimeout = 15_000 + connection.readTimeout = 30_000 + connection.instanceFollowRedirects = true + connection.inputStream.use { input -> + val buffer = ByteArray(256 * 1024) + while (read < bytes) { + val count = input.read(buffer) + if (count <= 0) break + read += count + } } - } - }.isSuccess + }.isSuccess connection.disconnect() if (ok && read > 0) return (SystemClock.elapsedRealtime() - start) to read } diff --git a/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt b/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt index 4bdb73cf6..0c3aa5430 100644 --- a/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt +++ b/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt @@ -47,6 +47,78 @@ class ConversationShadeTest { NotificationPresenter.cancel(context, 7L) NotificationPresenter.cancel(context, -5L) NotificationPresenter.cancel(context, -1_000_000_000_009L) + NotificationPresenter.cancel(context, 7L, accountUserId = 11001L) + NotificationPresenter.cancel(context, 7L, accountUserId = 22002L) + } + + @Test + fun legacyCleanupRemovesOnlyUnownedChatNotifications() { + NotificationChannels.ensure(context) + val preferences = context.getSharedPreferences( + "notification_migrations", + android.content.Context.MODE_PRIVATE + ) + val previous = preferences.getBoolean("account_scoped_shade", false) + val manager = context.getSystemService(NotificationManager::class.java) + val unrelatedId = 100001 + try { + preferences.edit().putBoolean("account_scoped_shade", false).commit() + val payload = parsePushPayload( + """{"loc_key":"MESSAGE_TEXT","loc_args":["Ada","hello"],"custom":{"from_id":7,"msg_id":71}}""", + ) + val decision = + NotificationDecision(true, true, false, false, false, true, PushChannelKind.Private) + val scoped = org.monogram.core.common.push.NotificationLocalStore(context, 11001L) + scoped.saveNotificationLedger(7L, org.monogram.core.common.push.NotificationLedger()) + NotificationPresenter.show(context, payload, decision) + NotificationPresenter.show(context, payload, decision, accountUserId = 11001L) + val unrelated = NotificationCompat.Builder(context, NotificationChannels.OTHER) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle("Unrelated") + .build() + manager.notify(unrelatedId, unrelated) + val legacyId = NotificationPresenter.notificationIdFor(payload, null) + val scopedId = NotificationPresenter.notificationIdFor(payload, 11001L) + awaitState { posted(legacyId) != null && posted(scopedId) != null && posted(unrelatedId) != null } + + NotificationPresenter.clearLegacyNotifications(context) + + awaitState { posted(legacyId) == null } + assertNotNull(posted(scopedId)) + assertNotNull(posted(unrelatedId)) + } finally { + manager.cancel(unrelatedId) + preferences.edit().putBoolean("account_scoped_shade", previous).commit() + } + } + + @Test + fun identicalChatPushesPostIndependentlyForTwoAccounts() { + NotificationChannels.ensure(context) + val firstAccount = 11001L + val secondAccount = 22002L + val firstStore = org.monogram.core.common.push.NotificationLocalStore(context, firstAccount) + val secondStore = + org.monogram.core.common.push.NotificationLocalStore(context, secondAccount) + firstStore.saveNotificationLedger(7L, org.monogram.core.common.push.NotificationLedger()) + secondStore.saveNotificationLedger(7L, org.monogram.core.common.push.NotificationLedger()) + val payload = parsePushPayload( + """{"loc_key":"MESSAGE_TEXT","loc_args":["Ada","hello"],"custom":{"from_id":7,"msg_id":71}}""", + ) + val decision = + NotificationDecision(true, true, false, false, false, true, PushChannelKind.Private) + + NotificationPresenter.show(context, payload, decision, accountUserId = firstAccount) + NotificationPresenter.show(context, payload, decision, accountUserId = secondAccount) + android.os.SystemClock.sleep(300) + + val first = posted(NotificationPresenter.notificationIdFor(payload, firstAccount)) + val second = posted(NotificationPresenter.notificationIdFor(payload, secondAccount)) + assertNotNull(first) + assertNotNull(second) + assertTrue(first!!.id != second!!.id) + assertEquals("account:$firstAccount:chat:7", first.notification.shortcutId) + assertEquals("account:$secondAccount:chat:7", second.notification.shortcutId) } @Test diff --git a/app/src/androidTest/java/org/monogram/root/AccountDurableUpdateSinkTest.kt b/app/src/androidTest/java/org/monogram/root/AccountDurableUpdateSinkTest.kt new file mode 100644 index 000000000..0bb32235b --- /dev/null +++ b/app/src/androidTest/java/org/monogram/root/AccountDurableUpdateSinkTest.kt @@ -0,0 +1,181 @@ +package org.monogram.root + +import android.content.Context +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.database.MonogramDatabase +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.entity.MessageEntity +import org.monogram.core.database.entity.MessageHoleEntity +import org.monogram.core.database.entity.MetaEntity +import org.monogram.network.bridge.DurableUpdateBatch +import org.monogram.network.bridge.MtprotoUpdate +import org.monogram.network.bridge.UpdatesCursor +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class AccountDurableUpdateSinkTest { + private lateinit var context: Context + private lateinit var name: String + private lateinit var database: MonogramDatabase + + @Before + fun open() { + context = InstrumentationRegistry.getInstrumentation().targetContext + name = "account-sink-${UUID.randomUUID()}.db" + database = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + } + + @After + fun close() { + database.close() + context.deleteDatabase(name) + } + + @Test + fun channelTooLongInvalidatesOnlyItsChatAndCommitsChannelCursor() = runBlocking { + database.messageDao().upsertAll( + listOf( + MessageEntity( + -10042, + 41, + null, + "stale", + 100, + outgoing = false, + mediaCacheKey = null, + mediaKind = null + ), + MessageEntity( + -10043, + 42, + null, + "fresh", + 100, + outgoing = false, + mediaCacheKey = null, + mediaKind = null + ), + ), + ) + database.metaDao().upsert(MetaEntity("history_coverage:-10042", "1")) + database.metaDao().upsert(MetaEntity("history_coverage:-10043", "1")) + val batch = DurableUpdateBatch( + accountGeneration = 4, + batchId = 10, + updates = listOf(MtprotoUpdate.Ignored("channel_difference_too_long:-10042")), + cursor = null, + channelCursors = mapOf(-10042L to 40), + ) + + assertTrue(AccountDurableUpdateSink(OfflineWarmup(database)).commit(batch)) + + assertEquals( + emptyList(), + database.messageDao().latestForChat(-10042, 10).map { it.id }) + assertEquals(listOf(42), database.messageDao().latestForChat(-10043, 10).map { it.id }) + assertEquals(null, database.metaDao().get("history_coverage:-10042")) + assertEquals("1", database.metaDao().get("history_coverage:-10043")?.value) + assertEquals(40, database.updateCursorDao().get("updates.channel:-10042")?.channelPts) + } + + @Test + fun malformedChannelRecoveryMarkerRollsBackBatch() = runBlocking { + database.messageDao().upsertAll( + listOf( + MessageEntity( + -10042, + 41, + null, + "cached", + 100, + outgoing = false, + mediaCacheKey = null, + mediaKind = null + ) + ), + ) + val batch = DurableUpdateBatch( + accountGeneration = 4, + batchId = 11, + updates = listOf( + MtprotoUpdate.Ignored("channel_difference_too_long:-10042"), + MtprotoUpdate.Ignored("channel_difference_too_long:invalid"), + ), + cursor = null, + channelCursors = mapOf(-10042L to 40), + ) + + val failure = + runCatching { AccountDurableUpdateSink(OfflineWarmup(database)).commit(batch) }.exceptionOrNull() + + assertTrue(failure is IllegalArgumentException) + assertEquals(listOf(41), database.messageDao().latestForChat(-10042, 10).map { it.id }) + assertEquals(null, database.updateCursorDao().get("updates.channel:-10042")) + assertTrue(!database.updateCursorDao().isBatchApplied(4, 11)) + } + + @Test + fun differenceTooLongCommitsCursorAndInvalidatesHistoryIdempotently() = runBlocking { + database.messageDao().upsertAll( + listOf( + MessageEntity( + 7, + 41, + null, + "cached", + 100, + outgoing = false, + mediaCacheKey = null, + mediaKind = null + ), + MessageEntity( + 7, + -9, + 7, + "pending", + 101, + outgoing = true, + mediaCacheKey = null, + mediaKind = null, + pending = true, + randomId = 90 + ), + ), + ) + database.messageHoleDao().insert(MessageHoleEntity(7, 1, 20)) + database.metaDao().upsert(MetaEntity("history_coverage:7", "1")) + val batch = DurableUpdateBatch( + accountGeneration = 4, + batchId = 9, + updates = listOf(MtprotoUpdate.Ignored("difference_too_long")), + cursor = UpdatesCursor(pts = 12, qts = 3, date = 100, seq = 8), + channelCursors = emptyMap(), + ) + + val sink = AccountDurableUpdateSink(OfflineWarmup(database)) + assertTrue(sink.commit(batch)) + assertTrue(sink.commit(batch)) + + assertEquals(listOf(-9), database.messageDao().pendingForChat(7).map { it.id }) + assertEquals(emptyList(), database.messageDao().latestForChat(7, 10).map { it.id }) + assertEquals( + emptyList(), + database.messageHoleDao().forChat(7, MessageHoleEntity.SOURCE_HISTORY) + ) + assertEquals(null, database.metaDao().get("history_coverage:7")) + val cursor = database.updateCursorDao().get("updates.primary") + assertEquals(12, cursor?.pts) + assertEquals(3, cursor?.qts) + assertEquals(100, cursor?.date) + assertEquals(8, cursor?.seq) + } +} diff --git a/app/src/main/java/org/monogram/AccountRegistry.kt b/app/src/main/java/org/monogram/AccountRegistry.kt new file mode 100644 index 000000000..80255856a --- /dev/null +++ b/app/src/main/java/org/monogram/AccountRegistry.kt @@ -0,0 +1,255 @@ +package org.monogram + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.monogram.core.models.AuthSession +import java.io.File +import java.io.FileOutputStream + +/** Durable account identities and derived storage paths; never stores auth keys. */ +class AccountRegistry( + private val filesDir: File, +) { + private val file = File(filesDir, FILE_NAME) + private val backup = File(filesDir, "$FILE_NAME.bak") + private val temporary = File(filesDir, "$FILE_NAME.tmp") + private val records = LinkedHashMap() + + @Volatile + private var recoveryRequired = false + + @Volatile + @get:Synchronized + var activeUserId: Long? = null + get() { + checkReadable() + return field + } + private set + + @Synchronized + fun load(): List { + val state = when { + !file.exists() && !backup.exists() -> { + check(!temporary.exists()) { "Account registry recovery required" } + RegistryState() + } + + !file.exists() -> { + val recovered = json.decodeFromString(backup.readText()) + validatedRecords(recovered) + check(backup.renameTo(file)) { "Unable to recover account registry" } + recovered + } + + else -> runCatching { json.decodeFromString(file.readText()) }.getOrElse { primaryError -> + check(backup.exists()) { throw primaryError } + val recovered = json.decodeFromString(backup.readText()) + validatedRecords(recovered) + check(file.delete()) { "Unable to recover account registry" } + check(backup.renameTo(file)) { "Unable to recover account registry" } + recovered + } + } + val restored = validatedRecords(state) + if (temporary.exists()) { + check(temporary.isFile && temporary.delete()) { "Unable to finish account registry recovery" } + } + if (backup.exists()) check(backup.delete()) { "Unable to finish account registry recovery" } + records.clear() + restored.forEach { record -> + records[record.userId] = record + } + activeUserId = state.activeUserId.takeIf { it > 0 && records.containsKey(it) } + recoveryRequired = false + return records.values.toList() + } + + private fun validatedRecords(state: RegistryState): List { + val restored = state.accounts.mapIndexed { index, stored -> + checkNotNull(stored.toRecord(filesDir, allowLegacy = index == 0)) { + "Account registry contains an invalid account" + } + } + check(restored.map { it.userId } + .distinct().size == restored.size) { "Account registry has duplicate accounts" } + return restored + } + + @Synchronized + fun all(): List { + checkReadable() + return records.values.toList() + } + + @Synchronized + fun get(userId: Long): AccountRecord? { + checkReadable() + return records[userId] + } + + @Synchronized + fun activeForStartup(): AccountRecord? { + val active = activeUserId?.let(records::get) + check(records.isEmpty() || active != null) { "Account registry has no active account" } + return active + } + + @Synchronized + fun ensure(session: AuthSession, activate: Boolean = true): AccountRecord { + require(session.userId.value > 0 && session.dcId > 0) { "Invalid account identity" } + checkNoOrphanedStaging() + val existing = records[session.userId.value] + if (existing != null) { + val updated = existing.copy(dcId = session.dcId) + records[updated.userId] = updated + if (activate) activeUserId = updated.userId + persist() + return updated + } + val record = AccountRecord( + userId = session.userId.value, + dcId = session.dcId, + sessionPath = File(filesDir, "accounts/${session.userId.value}/mtproto.session.json"), + databaseName = "monogram-account-${session.userId.value}.db", + mediaPath = File(filesDir, "accounts/${session.userId.value}/media"), + ) + records[record.userId] = record + if (activate) activeUserId = record.userId + persist() + return record + } + + @Synchronized + fun adoptAuthorized(session: AuthSession): AccountRecord = + if (records.isEmpty() || records[session.userId.value]?.databaseName == "monogram.db") { + adoptLegacy(session) + } else { + ensure(session) + } + + @Synchronized + fun adoptLegacy(session: AuthSession): AccountRecord { + require(session.userId.value > 0 && session.dcId > 0) { "Invalid account identity" } + checkNoOrphanedStaging() + val record = records[session.userId.value] ?: AccountRecord( + userId = session.userId.value, + dcId = session.dcId, + sessionPath = File(filesDir, "mtproto.session.json"), + databaseName = "monogram.db", + mediaPath = File(filesDir, "media"), + ) + records[record.userId] = record.copy(dcId = session.dcId) + activeUserId = record.userId + persist() + return records[record.userId]!! + } + + @Synchronized + fun setActive(userId: Long) { + require(records.containsKey(userId)) { "Unknown account: $userId" } + checkNoOrphanedStaging() + activeUserId = userId + persist() + } + + @Synchronized + fun remove(userId: Long): AccountRecord? { + checkNoOrphanedStaging() + val removed = records.remove(userId) + if (activeUserId == userId) activeUserId = records.keys.firstOrNull() + if (removed != null) persist() + return removed + } + + private fun checkReadable() { + check(!recoveryRequired) { "Account registry recovery required" } + } + + private fun checkNoOrphanedStaging() { + checkReadable() + check(file.exists() || backup.exists() || !temporary.exists()) { "Account registry recovery required" } + } + + private fun persist() { + try { + writeState() + } catch (error: Exception) { + try { + load() + } catch (recoveryError: Exception) { + recoveryRequired = true + error.addSuppressed(recoveryError) + } + throw error + } + } + + private fun writeState() { + filesDir.mkdirs() + val accounts = records.values.map(AccountRecord::toStored) + val encoded = json.encodeToString(RegistryState(activeUserId ?: 0L, accounts)) + check(!backup.exists()) { "Account registry recovery required" } + check(temporary.createNewFile()) { "Account registry recovery required" } + FileOutputStream(temporary).use { output -> + output.write(encoded.toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + if (file.exists()) check(file.renameTo(backup)) { "Unable to back up account registry" } + if (!temporary.renameTo(file)) { + if (backup.exists()) check(backup.renameTo(file)) { "Unable to restore account registry" } + error("Unable to persist account registry") + } + check(!backup.exists() || backup.delete()) { "Unable to finish account registry write" } + } + + companion object { + private const val FILE_NAME = "accounts.json" + private val json = Json { ignoreUnknownKeys = true } + } +} + +@Serializable +private data class RegistryState( + val activeUserId: Long = 0L, + val accounts: List = emptyList(), +) + +@Serializable +private data class StoredAccount( + val userId: Long, + val dcId: Int, + val sessionPath: String, + val databaseName: String, + val mediaPath: String, +) + +data class AccountRecord( + val userId: Long, + val dcId: Int, + val sessionPath: File, + val databaseName: String, + val mediaPath: File, +) + +private fun AccountRecord.toStored(): StoredAccount = StoredAccount( + userId = userId, + dcId = dcId, + sessionPath = sessionPath.absolutePath, + databaseName = databaseName, + mediaPath = mediaPath.absolutePath, +) + +private fun StoredAccount.toRecord(filesDir: File, allowLegacy: Boolean): AccountRecord? { + if (userId <= 0L || dcId <= 0) return null + val legacy = allowLegacy && databaseName == "monogram.db" + val root = if (legacy) File(filesDir, "") else File(filesDir, "accounts/$userId") + return AccountRecord( + userId = userId, + dcId = dcId, + sessionPath = if (legacy) File(filesDir, "mtproto.session.json") + else File(root, "mtproto.session.json"), + databaseName = if (legacy) "monogram.db" else "monogram-account-$userId.db", + mediaPath = if (legacy) File(filesDir, "media") else File(root, "media"), + ) +} diff --git a/app/src/main/java/org/monogram/AccountRuntimeManager.kt b/app/src/main/java/org/monogram/AccountRuntimeManager.kt new file mode 100644 index 000000000..31fe07f83 --- /dev/null +++ b/app/src/main/java/org/monogram/AccountRuntimeManager.kt @@ -0,0 +1,275 @@ +package org.monogram + +import android.content.Context +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.monogram.core.common.AppLog +import org.monogram.core.common.Outcome +import org.monogram.core.common.TelegramCredentials +import org.monogram.core.database.DatabaseProvider +import org.monogram.core.database.MonogramDatabase +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.OutgoingMediaFiles +import org.monogram.core.database.SessionMetadataStore +import org.monogram.network.bridge.BridgedMtprotoClient +import org.monogram.network.bridge.ClientInitInfo +import java.io.File +import java.util.UUID + +/** Owns the independently fenced native and cache state for each registered account. */ +class AccountRuntimeManager( + private val context: Context, + private val registry: AccountRegistry, + private val credentials: TelegramCredentials, + private val initConnection: ClientInitInfo, +) { + private val mutex = Mutex() + private val runtimes = LinkedHashMap() + private val failedTeardowns = mutableSetOf() + + @Volatile + private var activeGeneration = 0L + + @Volatile + private var activeUserId: Long? = null + + suspend fun open(account: AccountRecord): AccountRuntime = mutex.withLock { + checkTeardown(account.userId) + runtimes[account.userId] ?: create(account).also { runtimes[account.userId] = it } + } + + suspend fun adopt( + account: AccountRecord, + client: BridgedMtprotoClient, + database: MonogramDatabase, + sessionStore: SessionMetadataStore, + warmup: OfflineWarmup, + ) = mutex.withLock { + checkTeardown(account.userId) + runtimes[account.userId]?.takeIf { it.client !== client }?.let(::closeRuntime) + runtimes[account.userId] = AccountRuntime(account, database, sessionStore, warmup, client) + if (registry.activeUserId == account.userId) { + activeUserId = account.userId + activeGeneration++ + } + } + + suspend fun switchTo(userId: Long): AccountRuntime = mutex.withLock { + checkTeardown(userId) + val account = registry.get(userId) ?: error("Unknown account: $userId") + val runtime = runtimes[userId] ?: create(account).also { runtimes[userId] = it } + registry.setActive(userId) + activeUserId = userId + activeGeneration++ + runtime + } + + suspend fun startAll(readyUserIds: Set) = coroutineScope { + registry.all().filter { it.userId in readyUserIds }.map { account -> + async { + try { + val runtime = mutex.withLock { + val registered = registry.get(account.userId) ?: return@async + checkTeardown(registered.userId) + runtimes[registered.userId] ?: create(registered).also { + runtimes[registered.userId] = it + } + } + if (runtime.sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0") return@async + if (runtime.client.connect() is Outcome.Err) { + AppLog.warn("startup", "account client connection failed") + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("startup", "account client connection failed unexpectedly") + } + } + }.awaitAll() + } + + suspend fun hibernateAll() = mutex.withLock { + runtimes.values.filter { it.account.userId !in failedTeardowns } + .forEach { it.client.hibernate() } + } + + suspend fun requireAvailable(userId: Long) = mutex.withLock { checkTeardown(userId) } + + suspend fun close(userId: Long, removeStorage: Boolean) = mutex.withLock { + checkTeardown(userId) + val runtime = runtimes[userId] + runtime?.let(::closeRuntime) + runtimes.remove(userId) + val account = runtime?.account ?: registry.get(userId) + if (account?.databaseName == "monogram.db") { + DatabaseProvider.closeDefault() + } else { + DatabaseProvider.close(userId) + } + if (activeUserId == userId) { + activeUserId = registry.all().firstOrNull { it.userId != userId }?.userId + activeUserId?.let(registry::setActive) + activeGeneration++ + } + if (removeStorage) { + if (account?.databaseName == "monogram.db") { + deleteLegacyAccountFiles(context.filesDir) + deleteLegacyOutgoingMedia(File(context.noBackupFilesDir, "outgoing-media")) + check(!account.mediaPath.exists() || account.mediaPath.deleteRecursively()) { + "Unable to remove legacy account media" + } + val database = context.getDatabasePath("monogram.db") + check(!database.exists() || context.deleteDatabase("monogram.db")) { + "Unable to remove legacy account database" + } + } else if (account != null) { + val accountDir = File(context.filesDir, "accounts/${account.userId}") + check(!accountDir.exists() || accountDir.deleteRecursively()) { + "Unable to remove account files" + } + val outgoingDir = File(context.noBackupFilesDir, "outgoing-media/${account.userId}") + check(!outgoingDir.exists() || outgoingDir.deleteRecursively()) { + "Unable to remove account outgoing media" + } + val database = context.getDatabasePath(account.databaseName) + val databaseExisted = database.exists() + val databaseDeleted = context.deleteDatabase(account.databaseName) + check(!databaseExisted || databaseDeleted) { "Unable to remove account database" } + val identity = File(context.filesDir, "session-identity-${account.userId}.json") + check(!identity.exists() || identity.delete()) { "Unable to remove account identity" } + } + registry.remove(userId) + } + } + + private fun checkTeardown(userId: Long) { + check(userId !in failedTeardowns) { "Account shutdown requires recovery" } + } + + private fun closeRuntime(runtime: AccountRuntime) { + runtime.invalidate() + try { + runtime.client.close() + } catch (failure: Throwable) { + failedTeardowns.add(runtime.account.userId) + throw failure + } + } + + fun activeGeneration(): Long = activeGeneration + fun activeUserId(): Long? = activeUserId + suspend fun active(): AccountRuntime? = mutex.withLock { + activeUserId?.takeIf { it !in failedTeardowns }?.let(runtimes::get) + } + + suspend fun all(): List = mutex.withLock { + runtimes.values.filter { it.account.userId !in failedTeardowns } + } + + private fun create(account: AccountRecord): AccountRuntime { + val database = if (account.databaseName == "monogram.db") { + DatabaseProvider.get(context) + } else { + DatabaseProvider.get(context, account.userId) + } + val session = SessionMetadataStore(database) + val warmup = OfflineWarmup( + database, + outgoingMediaFiles = OutgoingMediaFiles( + File( + context.noBackupFilesDir, + if (account.databaseName == "monogram.db") "outgoing-media" + else "outgoing-media/${account.userId}", + ) + ), + ) + val client = BridgedMtprotoClient( + credentials = credentials, + sessionPath = account.sessionPath.absolutePath, + initConnection = initConnection, + ) + return AccountRuntime(account, database, session, warmup, client) + } +} + +internal fun requireVacantAccountStorage( + filesDir: File, noBackupFilesDir: File, database: File, userId: Long +) { + val paths = listOf( + File(filesDir, "accounts/$userId"), + File(filesDir, "session-identity-$userId.json"), + File(noBackupFilesDir, "outgoing-media/$userId"), + database, + File("${database.path}-journal"), + File("${database.path}-wal"), + File("${database.path}-shm"), + ) + check(paths.none(File::exists)) { "Existing account storage requires recovery" } +} + +internal fun deleteLegacyAccountFiles(filesDir: File) { + listOf( + "mtproto.session.json", + "mtproto.session.json.tmp", + "mtproto.session.tmp", + "mtproto.session.json.key", + "mtproto.session.json.key.bak", + "mtproto.session.json.key.new", + "session-identity.json", + ).forEach { name -> + val file = File(filesDir, name) + check(!file.exists() || file.delete()) { "Unable to remove account file" } + } +} + +internal fun deleteLegacyOutgoingMedia(directory: File) { + if (!directory.exists()) return + val files = directory.listFiles() ?: error("Unable to list legacy outgoing media") + files.forEach { file -> + val name = file.name.removeSuffix(".part") + val id = name.removeSuffix(".bin") + val validName = name.endsWith(".bin") && runCatching { + UUID.fromString(id).toString() == id + }.getOrDefault(false) + if (validName && file.isFile && file.canonicalFile == file.absoluteFile) { + check(file.delete()) { "Unable to remove legacy outgoing media" } + } + } +} + +enum class AccountRuntimeStatus { + Authorized, SignInRequired, +} + +data class AccountRuntime( + val account: AccountRecord, + val database: MonogramDatabase, + val sessionStore: SessionMetadataStore, + val warmup: OfflineWarmup, + val client: BridgedMtprotoClient, +) { + @Volatile + var status: AccountRuntimeStatus = AccountRuntimeStatus.Authorized + private set + + fun markSignInRequired() { + status = AccountRuntimeStatus.SignInRequired + } + + fun markAuthorized() { + status = AccountRuntimeStatus.Authorized + } + + @Volatile + private var valid = true + + fun isValid(): Boolean = valid + + fun invalidate() { + valid = false + } +} diff --git a/app/src/main/java/org/monogram/AccountWorkManager.kt b/app/src/main/java/org/monogram/AccountWorkManager.kt new file mode 100644 index 000000000..216aee6b5 --- /dev/null +++ b/app/src/main/java/org/monogram/AccountWorkManager.kt @@ -0,0 +1,41 @@ +package org.monogram + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface AccountBackgroundWork { + fun isRunning(): Boolean + fun start(scope: CoroutineScope) + fun resume() + suspend fun stop() +} + +class AccountWorkManager( + private val scope: CoroutineScope, + private val accountId: (R) -> Long, + private val prepare: suspend (R) -> Unit = {}, + private val create: (R) -> AccountBackgroundWork, +) { + private val mutex = Mutex() + private val work = LinkedHashMap() + + suspend fun start(runtime: R) = mutex.withLock { + val userId = accountId(runtime) + val worker = work[userId] ?: create(runtime).also { work[userId] = it } + if (!worker.isRunning()) { + prepare(runtime) + worker.start(scope) + } + } + + suspend fun stop(userId: Long) = mutex.withLock { + work.remove(userId)?.stop() + } + + suspend fun resume(userId: Long) = mutex.withLock { + work[userId]?.resume() + } + + suspend fun runningAccountIds(): Set = mutex.withLock { work.keys.toSet() } +} diff --git a/app/src/main/java/org/monogram/MainActivity.kt b/app/src/main/java/org/monogram/MainActivity.kt index 2988bc0b5..4e3b6f870 100644 --- a/app/src/main/java/org/monogram/MainActivity.kt +++ b/app/src/main/java/org/monogram/MainActivity.kt @@ -34,7 +34,9 @@ import kotlinx.coroutines.withContext import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome import org.monogram.core.common.PerfLog +import org.monogram.core.database.SessionMetadataStore import org.monogram.core.ui.AppearanceSettings +import org.monogram.core.ui.ImageCache import org.monogram.core.ui.ThemePreference import org.monogram.core.ui.media.LocalPictureInPictureActive import org.monogram.core.ui.media.LocalPictureInPictureController @@ -44,7 +46,9 @@ import org.monogram.core.ui.media.MediaViewerPipStage import org.monogram.core.ui.media.MediaViewerSharedRoot import org.monogram.core.ui.media.PictureInPictureController import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.push.NotificationAccountRoute import org.monogram.push.NotificationPresenter +import org.monogram.push.notificationAccountRoute import org.monogram.root.IncomingShare import org.monogram.root.IncomingShareStager import org.monogram.root.RootComponent @@ -58,24 +62,31 @@ open class MainActivity : ComponentActivity() { private var inPictureInPicture by mutableStateOf(false) private var pendingIncomingShare: IncomingShare? = null private var shareIntentHandled = false + private var notificationIntentHandled = false + private var linkIntentHandled = false + private var accountSwitchUserId: Long? = null + private var accountSwitchDestination: RootComponent.Config? = null companion object { private const val STATE_INCOMING_SHARE = "main.incoming_share" private const val STATE_SHARE_INTENT_HANDLED = "main.share_intent_handled" + private const val STATE_NOTIFICATION_INTENT_HANDLED = "main.notification_intent_handled" + private const val STATE_LINK_INTENT_HANDLED = "main.link_intent_handled" + private const val STATE_SWITCH_USER_ID = "main.switch_user_id" + private const val STATE_SWITCH_DESTINATION = "main.switch_destination" } /** Only this activity owns the PiP window; the media viewer stays in its dialog. */ private val pictureInPicture = object : PictureInPictureController { override val supported: Boolean - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && packageManager.hasSystemFeature( + PackageManager.FEATURE_PICTURE_IN_PICTURE + ) override fun enter() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (!supported) return - val params = PictureInPictureParams.Builder() - .setAspectRatio(Rational(16, 9)) - .build() + val params = PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).build() runCatching { enterPictureInPictureMode(params) } } @@ -84,9 +95,7 @@ open class MainActivity : ComponentActivity() { if (!supported) return runCatching { setPictureInPictureParams( - PictureInPictureParams.Builder() - .setAspectRatio(Rational(16, 9)) - .build(), + PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).build(), ) } } @@ -112,6 +121,13 @@ open class MainActivity : ComponentActivity() { savedInstanceState?.getBundle(STATE_INCOMING_SHARE), ) shareIntentHandled = savedInstanceState?.getBoolean(STATE_SHARE_INTENT_HANDLED) == true + notificationIntentHandled = + savedInstanceState?.getBoolean(STATE_NOTIFICATION_INTENT_HANDLED) == true + linkIntentHandled = savedInstanceState?.getBoolean(STATE_LINK_INTENT_HANDLED) == true + accountSwitchUserId = savedInstanceState?.getLong(STATE_SWITCH_USER_ID)?.takeIf { it != 0L } + accountSwitchDestination = accountSwitchDestinationFromKey( + savedInstanceState?.getString(STATE_SWITCH_DESTINATION), + ) val crashLog = AppLog.readCrashLog() if (crashLog != null) { AppLog.clearCrashLog() @@ -131,75 +147,169 @@ open class MainActivity : ComponentActivity() { ) val app = application as MonogramApp val componentContext = defaultComponentContext() + lifecycleScope.launch { + app.accountStatusChanges.collect { + if (startupReady.isCompleted && !isFinishing) recreate() + } + } lifecycleScope.launch { app.awaitReady() + if (!notificationIntentHandled && isNotificationIntent(intent.action)) { + when (val route = notificationAccountRoute( + intent.getLongExtra(NotificationPresenter.EXTRA_ACCOUNT_USER_ID, 0L), + app.accountRegistry.activeUserId, + app.accountRegistry.all(), + )) { + NotificationAccountRoute.Reject -> { + finish() + return@launch + } + + NotificationAccountRoute.Open -> Unit + is NotificationAccountRoute.Switch -> app.switchAccount(route.userId) + } + } + val loadedAccountMenuItems = app.accountMenuItems().associateBy { it.userId } + val bindings = app.rootAccountBindings() + val accountMenuItems = bindings.accountUserIds.map { id -> + loadedAccountMenuItems[id]?.copy(active = id == bindings.account?.userId) + ?: org.monogram.feature.chats.AccountMenuItem( + userId = id, + title = getString( + org.monogram.feature.chats.R.string.chats_account_fallback, id + ), + active = id == bindings.account?.userId, + unavailable = true, + ) + } + + fun boundAccountId(): Long? = bindings.account?.userId + ?: app.accountRegistry.activeUserId?.takeIf { app.client === bindings.client } + val startOnHome = withContext(Dispatchers.IO) { resolveStartOnHome( - sessionAuthorized = { app.sessionStore.isAuthorized() }, - locallyAuthorized = { app.client.isLocallyAuthorized() }, + sessionAuthorized = { bindings.sessionStore.isAuthorized() }, + locallyAuthorized = { bindings.client.isLocallyAuthorized() }, + explicitlySignedOut = { + bindings.sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0" + }, ) } root = RootComponent( componentContext = componentContext, storeFactory = DefaultStoreFactory(), - client = app.client, - warmup = app.warmup, - sessionStore = app.sessionStore, - mediaRepository = app.mediaRepository, + client = bindings.client, + warmup = bindings.warmup, + sessionStore = bindings.sessionStore, + mediaRepository = bindings.mediaRepository, startOnHome = startOnHome, - pushRegistration = app.push, - notificationLocal = app.notifications, + accountSwitchDestination = accountSwitchDestination.takeIf { + accountSwitchUserId == bindings.account?.userId + }, + pushRegistration = bindings.push, + notificationLocal = bindings.notifications, onIncomingShareConsumed = { pendingIncomingShare = null }, appUpdate = app.appUpdate, bubbleChatId = if (this@MainActivity is BubbleActivity) { intent.getLongExtra(NotificationPresenter.EXTRA_CHAT_ID, 0L).takeIf { it != 0L } } else null, - liveLocations = app.liveLocations, + liveLocations = bindings.liveLocations, onRootBack = { finish() }, + onAccountWorkStop = { + boundAccountId()?.let { app.stopAccountWork(it) } + }, + onAccountWorkResume = { + if (app.client === bindings.client) app.resumeActiveAccountWork() + }, + onAccountAuthorized = app::adoptAuthorizedAccount, + onBeginAddAccount = app::beginAdditionalAuthorization, + onAdditionalAccountAuthorized = { session, pending -> + app.adoptAdditionalAuthorized(session, pending) + recreate() + }, + onAccountLoggedOut = { + val userId = boundAccountId() + if (userId != null) app.stopAccountWork(userId) + if (userId != null && app.accountRegistry.all().size > 1) { + app.removeAccount(userId) + recreate() + } + }, + accountUserIds = bindings.accountUserIds, + accountMenuItems = accountMenuItems, + loadAccountMenuItems = app::accountMenuItems, + loadAccountDetails = app::loadSettingsAccountDetails, + accountNeedsSignInIds = accountMenuItems.filter { it.needsSignIn } + .mapTo(mutableSetOf()) { it.userId }, + activeAccountUserId = bindings.account?.userId, + activeAccountDcId = bindings.account?.dcId, + onSwitchAccount = { userId -> + val destination = root.accountSwitchDestination() + app.switchAccount(userId) + accountSwitchUserId = userId + accountSwitchDestination = destination + recreate() + }, + onRemoveAccount = { userId -> + app.removeAccount(userId) + recreate() + }, ) + accountSwitchUserId = null + accountSwitchDestination = null if (pendingIncomingShare != null) { root.openIncomingShare(pendingIncomingShare!!) - } else if (!shareIntentHandled) { + } else if (shouldHandleIncomingIntent( + intent.action, + shareIntentHandled, + notificationIntentHandled, + linkIntentHandled + ) + ) { handleIncomingIntent(intent) } + val imageCacheRoot = bindings.mediaRepository.cacheRoot setContent { - val appearance by AppearanceSettings.state.collectAsState() - val systemDark = isSystemInDarkTheme() - val darkTheme = when (appearance.theme) { - ThemePreference.Light -> false - ThemePreference.Dark -> true - ThemePreference.System -> systemDark - } - MonogramTheme( - darkTheme = darkTheme, - dynamicColor = appearance.dynamicColor, - accentPreset = appearance.accentPreset, - ) { - val pip = remember { pictureInPicture } - CompositionLocalProvider( - LocalPictureInPictureController provides pip, - LocalPictureInPictureActive provides inPictureInPicture, + ImageCache.AccountScope(imageCacheRoot) { + val appearance by AppearanceSettings.state.collectAsState() + val systemDark = isSystemInDarkTheme() + val darkTheme = when (appearance.theme) { + ThemePreference.Light -> false + ThemePreference.Dark -> true + ThemePreference.System -> systemDark + } + MonogramTheme( + darkTheme = darkTheme, + dynamicColor = appearance.dynamicColor, + accentPreset = appearance.accentPreset, ) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.surface, + val pip = remember { pictureInPicture } + CompositionLocalProvider( + LocalPictureInPictureController provides pip, + LocalPictureInPictureActive provides inPictureInPicture, ) { - if (inPictureInPicture) { - val session = MediaPlaybackHolder.peek() - if (session?.current != null) { - MediaViewerPipStage( - session = session, - modifier = Modifier.fillMaxSize() - ) + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface, + ) { + if (inPictureInPicture) { + val session = MediaPlaybackHolder.peek() + if (session?.current != null) { + MediaViewerPipStage( + session = session, modifier = Modifier.fillMaxSize() + ) + } else { + RootContent( + component = root, modifier = Modifier.fillMaxSize() + ) + } } else { - RootContent(component = root, modifier = Modifier.fillMaxSize()) - } - } else { - MediaViewerSharedRoot { - RootContent( - component = root, - modifier = Modifier.fillMaxSize(), - ) + MediaViewerSharedRoot { + RootContent( + component = root, + modifier = Modifier.fillMaxSize(), + ) + } } } } @@ -216,11 +326,19 @@ open class MainActivity : ComponentActivity() { if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) { shareIntentHandled = false } + if (isNotificationIntent(intent.action)) notificationIntentHandled = false + if (intent.dataString != null) linkIntentHandled = false if (startupReady.isCompleted) handleIncomingIntent(intent) } override fun onSaveInstanceState(outState: Bundle) { pendingIncomingShare?.toBundle()?.let { outState.putBundle(STATE_INCOMING_SHARE, it) } + outState.putBoolean(STATE_NOTIFICATION_INTENT_HANDLED, notificationIntentHandled) + outState.putBoolean(STATE_LINK_INTENT_HANDLED, linkIntentHandled) + accountSwitchUserId?.let { outState.putLong(STATE_SWITCH_USER_ID, it) } + accountSwitchDestination?.let { + outState.putString(STATE_SWITCH_DESTINATION, accountSwitchDestinationKey(it)) + } outState.putBoolean( STATE_SHARE_INTENT_HANDLED, shareIntentHandled || runCatching { root.hasIncomingShare() }.getOrDefault(false), @@ -240,15 +358,11 @@ open class MainActivity : ComponentActivity() { val started = PerfLog.nowMs() when (val result = runCatching { app.client.connect() }.getOrNull()) { is Outcome.Ok -> PerfLog.mark( - "activity:connect", - PerfLog.nowMs() - started, - "result=ok" + "activity:connect", PerfLog.nowMs() - started, "result=ok" ) is Outcome.Err -> PerfLog.mark( - "activity:connect", - PerfLog.nowMs() - started, - "result=err" + "activity:connect", PerfLog.nowMs() - started, "result=err" ) null -> PerfLog.mark("activity:connect", PerfLog.nowMs() - started, "result=throw") @@ -276,6 +390,29 @@ open class MainActivity : ComponentActivity() { } return } + if (isNotificationIntent(intent.action)) { + val registry = (application as MonogramApp).accountRegistry + val accountId = intent.getLongExtra(NotificationPresenter.EXTRA_ACCOUNT_USER_ID, 0L) + when (val route = notificationAccountRoute( + accountId, + registry.activeUserId, + registry.all(), + )) { + NotificationAccountRoute.Reject -> { + notificationIntentHandled = true + return + } + NotificationAccountRoute.Open -> Unit + is NotificationAccountRoute.Switch -> { + lifecycleScope.launch { + (application as MonogramApp).switchAccount(route.userId) + recreate() + } + return + } + } + notificationIntentHandled = true + } if (intent.action == NotificationPresenter.ACTION_OPEN_CHAT) { val chatId = intent.getLongExtra(NotificationPresenter.EXTRA_CHAT_ID, 0L) val messageId = intent.getIntExtra(NotificationPresenter.EXTRA_MESSAGE_ID, 0) @@ -288,5 +425,43 @@ open class MainActivity : ComponentActivity() { } val uri = intent.dataString ?: return root.openTelegramUri(uri) + linkIntentHandled = true + } +} + +internal fun isNotificationIntent(action: String?): Boolean = + action == NotificationPresenter.ACTION_OPEN_CHAT || action == NotificationPresenter.ACTION_OPEN_NOTIFICATIONS + +internal fun shouldHandleIncomingIntent( + action: String?, + shareHandled: Boolean, + notificationHandled: Boolean, + linkHandled: Boolean = false, +): Boolean = when { + action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE -> !shareHandled + isNotificationIntent(action) -> !notificationHandled + else -> !linkHandled +} + +internal fun accountSwitchDestinationKey(destination: RootComponent.Config): String = + when (destination) { + is RootComponent.Config.Settings -> when { + destination.openProxy -> "settings_proxy" + destination.openFolders -> "settings_folders" + destination.openNotifications -> "settings_notifications" + else -> "settings" + } + + RootComponent.Config.Proxy -> "proxy" + else -> "home" } + +internal fun accountSwitchDestinationFromKey(key: String?): RootComponent.Config? = when (key) { + "home" -> RootComponent.Config.Home + "settings" -> RootComponent.Config.Settings() + "settings_proxy" -> RootComponent.Config.Settings(openProxy = true) + "settings_folders" -> RootComponent.Config.Settings(openFolders = true) + "settings_notifications" -> RootComponent.Config.Settings(openNotifications = true) + "proxy" -> RootComponent.Config.Proxy + else -> null } diff --git a/app/src/main/java/org/monogram/MonogramApp.kt b/app/src/main/java/org/monogram/MonogramApp.kt index 5fba7a656..871ac1270 100644 --- a/app/src/main/java/org/monogram/MonogramApp.kt +++ b/app/src/main/java/org/monogram/MonogramApp.kt @@ -1,6 +1,8 @@ package org.monogram +import android.Manifest.permission import android.app.Application +import android.content.pm.PackageManager import android.os.Build import androidx.compose.material3.ComposeMaterial3Flags import androidx.compose.material3.ExperimentalMaterial3Api @@ -11,10 +13,19 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.monogram.core.common.AppLog import org.monogram.core.common.DebugLog @@ -29,7 +40,10 @@ import org.monogram.core.common.push.NotificationLocalStore import org.monogram.core.database.DatabaseProvider import org.monogram.core.database.MonogramDatabase import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.OutgoingMediaFiles import org.monogram.core.database.SessionMetadataStore +import org.monogram.core.models.AuthSession +import org.monogram.core.models.GeoPlace import org.monogram.core.models.PeerId import org.monogram.core.ui.AppUpdateSettings import org.monogram.core.ui.AppearanceSettings @@ -39,11 +53,16 @@ import org.monogram.core.ui.FileLeases import org.monogram.core.ui.ImageCache import org.monogram.core.ui.currentLocation import org.monogram.core.ui.perf.perfSpan +import org.monogram.feature.chats.AccountMenuItem +import org.monogram.feature.dialog.clearDialogMemory +import org.monogram.feature.dialog.ui.stopAudioMessagePlayback import org.monogram.feature.settings.ProxySettingsStore +import org.monogram.feature.settings.SettingsAccountDetails import org.monogram.mtproto.MtprotoNativeLoader import org.monogram.network.bridge.BridgedMtprotoClient import org.monogram.network.bridge.ClientInitInfo import org.monogram.network.bridge.MtprotoTransportMode +import org.monogram.network.bridge.MtprotoUpdate import org.monogram.network.bridge.ProxyConfig import org.monogram.network.bridge.ProxyType import org.monogram.network.http.MediaFetchKind @@ -53,16 +72,29 @@ import org.monogram.network.http.TelegramChunkFetcher import org.monogram.network.http.TelegramInlineThumbPeek import org.monogram.network.http.TelegramMediaFetcher import org.monogram.network.http.TelegramPeerInlineThumbPeek +import org.monogram.push.AccountPushRouter +import org.monogram.push.NotificationPresenter import org.monogram.push.PushCoordinator +import org.monogram.push.SharedPushSecret +import org.monogram.root.AccountDurableUpdateSink +import org.monogram.root.CloudDraftSync import org.monogram.root.LiveLocationRuntime +import org.monogram.root.OutgoingTextSync import org.monogram.root.editRetainedLiveLocation +import org.monogram.root.sendRetainedMessage import org.monogram.sponsor.SponsorSyncManager import org.monogram.update.AndroidAppUpdateInstaller import org.monogram.update.AppUpdateManager import java.io.File import java.util.Locale +import java.util.UUID class MonogramApp : Application() { + private lateinit var credentials: TelegramCredentials + lateinit var accountRegistry: AccountRegistry + private set + lateinit var accountRuntimeManager: AccountRuntimeManager + private set lateinit var client: BridgedMtprotoClient private set lateinit var mediaRepository: MediaRepository @@ -70,6 +102,8 @@ class MonogramApp : Application() { lateinit var notifications: NotificationLocalStore private set lateinit var push: PushCoordinator + lateinit var pushRouter: AccountPushRouter + private set lateinit var sponsorSync: SponsorSyncManager private set @@ -81,25 +115,141 @@ class MonogramApp : Application() { private set private val settingsScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val ready = CompletableDeferred() - internal val liveLocations by lazy { - LiveLocationRuntime(activityScope, warmup, sample = { - currentLocation( - this, allowCached = false - )?.let { org.monogram.core.models.GeoPlace(it.first, it.second) } - }, permitted = { - ContextCompat.checkSelfPermission( - this, android.Manifest.permission.ACCESS_COARSE_LOCATION - ) == android.content.pm.PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission( - this, android.Manifest.permission.ACCESS_FINE_LOCATION - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - }, edit = { id, point -> - val epoch = warmup.outgoingAccountEpoch() - editRetainedLiveLocation( - client, id, point - ) { warmup.outgoingAccountIsCurrent(epoch) } - }) + private val accountOperations = Mutex() + private val accountWorkManager: AccountWorkManager by lazy { + AccountWorkManager( + scope = settingsScope, + accountId = { it.account.userId }, + prepare = { + it.markAuthorized() + it.warmup.activateOutgoingAccount() + }, + ) { runtime -> + val draft = CloudDraftSync( + runtime.warmup, + runtime.client::getCloudDrafts, + runtime.client::saveCloudDraft, + ) + val outgoing = OutgoingTextSync( + runtime.warmup, + send = { row -> sendRetainedMessage(runtime.client, row) }, + sendMedia = { row -> + org.monogram.root.sendRetainedMedia( + runtime.warmup, + runtime.client, + row, + ) + }, + ) + object : AccountBackgroundWork { + @Volatile + private var running = false + private var updateJob: Job? = null + private var sessionLostJob: Job? = null + + override fun isRunning(): Boolean = running + + override fun start(scope: CoroutineScope) { + running = true + runtime.client.installDurableUpdateSink( + AccountDurableUpdateSink(runtime.warmup)::commit, + ) + draft.start(scope) + outgoing.start(scope) + updateJob = scope.launch { + runtime.client.updates().collect { update -> + when (update) { + is MtprotoUpdate.ChatsChanged -> { + runtime.warmup.upsertChats( + update.chats, + ) + } + + is MtprotoUpdate.SyncState -> { + if (!update.isSyncing) resume() + } + + else -> { + Unit + } + } + } + } + sessionLostJob = scope.launch { + runtime.client.sessionLost().first() + runtime.markSignInRequired() + accountStatusEvents.tryEmit(runtime.account.userId) + running = false + updateJob?.cancel() + outgoing.stop() + draft.stop() + runtime.warmup.deactivateOutgoingAccount() + } + } + + override fun resume() { + if (!running) return + draft.resume() + outgoing.resume() + } + + override suspend fun stop() { + running = false + updateJob?.cancelAndJoin() + updateJob = null + sessionLostJob?.cancelAndJoin() + sessionLostJob = null + outgoing.stop() + draft.stop() + runtime.warmup.deactivateOutgoingAccount() + } + } + } } + private val sharedPushSecret by lazy { SharedPushSecret(this) } + private val accountStatusEvents = MutableSharedFlow(extraBufferCapacity = 8) + val accountStatusChanges = accountStatusEvents.asSharedFlow() + private val pushRouterCoordinatorIds = mutableSetOf() + private val accountPushes = mutableMapOf() + private val accountMediaRepositories = + java.util.concurrent.ConcurrentHashMap() + + @Volatile + private var bootstrapMediaRepository: MediaRepository? = null + + private val ready = CompletableDeferred() + private val pushReady = CompletableDeferred() + private val accountLiveLocations = mutableMapOf() + internal val liveLocations: LiveLocationRuntime + get() { + val client = client + val warmup = warmup + return accountLiveLocations.getOrPut(client) { + LiveLocationRuntime(activityScope, warmup, sample = { + currentLocation( + this, + allowCached = false, + )?.let { + GeoPlace(it.first, it.second) + } + }, permitted = { + ContextCompat.checkSelfPermission( + this, + permission.ACCESS_COARSE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission( + this, + permission.ACCESS_FINE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED + }, edit = { id, point -> + val epoch = warmup.outgoingAccountEpoch() + editRetainedLiveLocation( + client, + id, + point, + ) { warmup.outgoingAccountIsCurrent(epoch) } + }) + } + } private val activityScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val startedActivities = mutableSetOf() private var activityIdleJob: Job? = null @@ -119,18 +269,384 @@ class MonogramApp : Application() { activityIdleJob = activityScope.launch { awaitReady() val foreground = startedActivities.isNotEmpty() - push.setForeground(foreground) - if (!foreground) { - delay(120_000) - if (startedActivities.isEmpty() && notifications.token().isNotBlank()) { - client.hibernate() + val activeUserId = accountRegistry.activeUserId + if (activeUserId == null) push.setForeground(foreground) + pushRouter.setForeground(foreground, activeUserId) + } + } + + private suspend fun startAccountPushes(readyUserIds: Set? = null) { + accountRuntimeManager.all().forEach { runtime -> + val userId = runtime.account.userId + if (readyUserIds != null && userId !in readyUserIds) return@forEach + if (pushRouterCoordinatorIds.contains(userId)) return@forEach + var createdCoordinator: PushCoordinator? = null + try { + if (runtime.sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0") return@forEach + val accountNotifications = NotificationLocalStore( + this, + userId, + migrateLegacy = runtime.account.databaseName == "monogram.db", + ) + val accountMedia = accountMedia(runtime) + val coordinator = PushCoordinator( + this, + runtime.client, + accountNotifications, + accountMedia, + DefaultStoreFactory(), + runtime.sessionStore, + otherAccountUserIds = { + accountRegistry.all().map { it.userId } + }, + sharedPushSecret = sharedPushSecret::get, + notificationCache = runtime.warmup, + ) + createdCoordinator = coordinator + coordinator.setAccountUserId(userId) + coordinator.start() + pushRouter.register(userId, coordinator) + accountPushes[userId] = coordinator + pushRouterCoordinatorIds += userId + } catch (error: Exception) { + try { + createdCoordinator?.stop() + pushRouter.unregister(userId) + accountPushes.remove(userId) + pushRouterCoordinatorIds.remove(userId) + } catch (cleanupError: Exception) { + error.addSuppressed(cleanupError) } + if (error is CancellationException) throw error + AppLog.warn("push", "account push setup unavailable") } } + accountPushes.values.toList().forEach(PushCoordinator::requestReregister) + } + + data class PendingAccountAuthorization( + val client: BridgedMtprotoClient, + val sessionPath: File, + ) + + fun beginAdditionalAuthorization(): PendingAccountAuthorization { + val path = File(filesDir, "accounts/.pending/${UUID.randomUUID()}/mtproto.session.json") + return PendingAccountAuthorization( + BridgedMtprotoClient( + credentials = credentials, + sessionPath = path.absolutePath, + initConnection = monogramInitConnection(), + ), + path, + ) + } + + suspend fun adoptAdditionalAuthorized( + session: AuthSession, + pending: PendingAccountAuthorization, + ) = accountOperations.withLock { + val accountId = session.userId.value + check(accountRegistry.get(accountId) == null) { "Account is already registered" } + val sessionPath = File(filesDir, "accounts/$accountId/mtproto.session.json") + requireVacantAccountStorage( + filesDir, + noBackupFilesDir, + getDatabasePath("monogram-account-$accountId.db"), + accountId, + ) + val previousAccountId = accountRuntimeManager.activeUserId() ?: accountRegistry.activeUserId + var accountClient: BridgedMtprotoClient? = null + var runtimeAdopted = false + try { + sessionPath.parentFile?.mkdirs() + pending.client.close() + MtprotoNativeLoader.promoteSession(pending.sessionPath, sessionPath) + val account = accountRegistry.ensure(session, activate = false) + check(account.sessionPath == sessionPath) { "Promoted session path mismatch" } + val database = DatabaseProvider.get(this, account.userId) + val accountSessionStore = SessionMetadataStore(database) + accountSessionStore.saveAuthorized(session) + val accountWarmup = OfflineWarmup( + database, + outgoingMediaFiles = OutgoingMediaFiles( + File(noBackupFilesDir, "outgoing-media/${account.userId}"), + ), + ) + val client = BridgedMtprotoClient( + credentials = credentials, + sessionPath = account.sessionPath.absolutePath, + initConnection = monogramInitConnection(), + ) + accountClient = client + accountRuntimeManager.adopt( + account, + client, + database, + accountSessionStore, + accountWarmup, + ) + runtimeAdopted = true + accountWorkManager.start(accountRuntimeManager.switchTo(account.userId)) + client.connect() + applyDownloadSettings(client, DownloadSettings.state.value) + startAccountPushes() + bindActiveRuntime(accountRuntimeManager.switchTo(account.userId)) + pending.sessionPath.parentFile?.deleteRecursively() + } catch (error: Throwable) { + withContext(NonCancellable) { + try { + if (!runtimeAdopted) accountClient?.close() + } catch (cleanupError: Throwable) { + error.addSuppressed(cleanupError) + } + try { + if (previousAccountId != null && accountRegistry.get(previousAccountId) != null) { + bindActiveRuntime(accountRuntimeManager.switchTo(previousAccountId)) + } + } catch (restoreError: Throwable) { + error.addSuppressed(restoreError) + } + } + throw error + } + } + + suspend fun adoptAuthorizedAccount(session: AuthSession) = accountOperations.withLock { + check( + accountRegistry.all().isEmpty() || accountRegistry.activeUserId == session.userId.value, + ) { + "Authorization requires an account-specific runtime" + } + check(push.usesClient(client)) { "Account push coordinator unavailable" } + val account = accountRegistry.adoptAuthorized(session) + val database = if (account.databaseName == "monogram.db") { + DatabaseProvider.get(this) + } else { + DatabaseProvider.get(this, account.userId) + } + accountRuntimeManager.adopt(account, client, database, sessionStore, warmup) + val existingMedia = accountMediaRepositories.putIfAbsent(account.userId, mediaRepository) + check(existingMedia == null || existingMedia === mediaRepository) { "Account media repository mismatch" } + bootstrapMediaRepository = null + accountWorkManager.start(accountRuntimeManager.switchTo(account.userId)) + push.setAccountUserId(account.userId) + pushRouter.register(account.userId, push) + accountPushes[account.userId] = push + pushRouterCoordinatorIds += account.userId + pushRouter.setForeground(startedActivities.isNotEmpty(), account.userId) + } + + internal data class RootAccountBindings( + val account: AccountRecord?, + val accountUserIds: List, + val client: BridgedMtprotoClient, + val sessionStore: SessionMetadataStore, + val warmup: OfflineWarmup, + val mediaRepository: MediaRepository, + val notifications: NotificationLocalStore, + val push: PushCoordinator?, + val liveLocations: LiveLocationRuntime, + ) + + internal suspend fun rootAccountBindings(): RootAccountBindings = accountOperations.withLock { + val accounts = accountRegistry.all() + val activeId = accountRegistry.activeUserId + val account = activeId?.let { id -> accounts.firstOrNull { it.userId == id } } + check(activeId == null || account != null) { "Active account unavailable" } + if (account != null) { + val runtime = accountRuntimeManager.active() + check( + runtime != null && runtime.isValid() && runtime.account.userId == account.userId && runtime.client === client && runtime.sessionStore === sessionStore && runtime.warmup === warmup && accountMediaRepositories[account.userId] === mediaRepository, + ) { "Active account runtime mismatch" } + } + val coordinator = + if (activeId == null) push else pushRouter.coordinator(activeId) as? PushCoordinator + RootAccountBindings( + account, + accounts.map { it.userId }, + client, + sessionStore, + warmup, + mediaRepository, + notifications, + coordinator?.takeIf { it.usesClient(client) }, + liveLocations, + ) + } + + suspend fun switchAccount(userId: Long) = accountOperations.withLock { + if (accountRuntimeManager.activeUserId() != userId) { + stopAudioMessagePlayback() + } + val runtime = accountRuntimeManager.switchTo(userId) + bindActiveRuntime(runtime) + settingsScope.launch { + if (!runtime.isValid() || runtime.status == AccountRuntimeStatus.SignInRequired || runtime.sessionStore.readMeta( + SessionMetadataStore.KEY_AUTHORIZED, + ) == "0" + ) { + return@launch + } + runtime.client.connect() + applyDownloadSettings(runtime.client, DownloadSettings.state.value) + } + } + + suspend fun removeAccount(userId: Long) = accountOperations.withLock { + val account = accountRegistry.get(userId) ?: return@withLock + require(canRemoveAccount(accountRegistry.all().size)) { "Cannot remove the last account" } + accountRuntimeManager.requireAvailable(userId) + pushRouter.unregister(userId) + NotificationPresenter.clear(this, userId) + NotificationLocalStore.clearAccount(this, userId) + accountPushes.remove(userId) + pushRouterCoordinatorIds.remove(userId) + accountWorkManager.stop(userId) + accountMediaRepositories.remove(userId)?.shutdown() + ImageCache.release(account.mediaPath) + accountRuntimeManager.all().firstOrNull { it.account.userId == userId }?.let { runtime -> + clearDialogMemory(runtime.client) + if (runtime.client === client) stopAudioMessagePlayback() + accountLiveLocations.remove(runtime.client)?.stopAuthorized() + } + accountRuntimeManager.close(userId, removeStorage = true) + val nextId = accountRuntimeManager.activeUserId() + val nextRuntime = nextId?.let { accountRuntimeManager.switchTo(it) } + nextRuntime?.let(::bindActiveRuntime) + if (nextRuntime != null) { + try { + if (nextRuntime.isValid() && nextRuntime.status != AccountRuntimeStatus.SignInRequired && nextRuntime.sessionStore.readMeta( + SessionMetadataStore.KEY_AUTHORIZED, + ) != "0" + ) { + if (nextRuntime.client.connect() is Outcome.Err) { + AppLog.warn("account", "remaining account connection failed") + } + applyDownloadSettings(nextRuntime.client, DownloadSettings.state.value) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("account", "remaining account connection failed unexpectedly") + } + } + startAccountPushes() + } + + private fun bindActiveRuntime(runtime: AccountRuntime) { + client = runtime.client + mediaRepository = accountMedia(runtime) + notifications = NotificationLocalStore( + this, + runtime.account.userId, + migrateLegacy = runtime.account.databaseName == "monogram.db", + ) + sessionStore = runtime.sessionStore + warmup = runtime.warmup + push = accountPushes[runtime.account.userId] ?: push + val foreground = startedActivities.isNotEmpty() + pushRouter.setForeground(foreground, runtime.account.userId) + } + + suspend fun accountMenuItems(): List = accountRegistry.all().map { account -> + try { + val runtime = accountRuntimeManager.open(account) + val authorized = + runtime.status != AccountRuntimeStatus.SignInRequired && runtime.sessionStore.isAuthorized() + val profile = runtime.sessionStore.readProfile(account.userId) + val title = profile?.title?.takeIf { it.isNotBlank() } ?: getString( + org.monogram.feature.chats.R.string.chats_account_fallback, + account.userId, + ) + AccountMenuItem( + userId = account.userId, + title = title, + needsSignIn = !authorized, + active = account.userId == accountRegistry.activeUserId, + avatarCacheKey = profile?.avatarCacheKey, + mediaRepository = accountMedia(runtime), + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("account", "account information unavailable") + AccountMenuItem( + userId = account.userId, + title = getString( + org.monogram.feature.chats.R.string.chats_account_fallback, + account.userId, + ), + active = account.userId == accountRegistry.activeUserId, + unavailable = true, + ) + } + } + + suspend fun loadSettingsAccountDetails( + userId: Long, + publish: suspend (SettingsAccountDetails) -> Unit, + ) = withContext(Dispatchers.IO) { + val (runtime, repository, cached) = accountOperations.withLock { + val account = accountRegistry.get(userId) ?: return@withContext + val runtime = accountRuntimeManager.open(account) + Triple(runtime, accountMedia(runtime), runtime.sessionStore.readProfile(userId)) + } + publish( + SettingsAccountDetails( + title = cached?.title?.takeIf { it.isNotBlank() }, + avatarCacheKey = cached?.avatarCacheKey, + mediaRepository = repository, + username = cached?.username, + phone = cached?.phone, + ), + ) + currentCoroutineContext().ensureActive() + if (!runtime.isValid() || runtime.status == AccountRuntimeStatus.SignInRequired || !runtime.sessionStore.isAuthorized() || runtime.sessionStore.readAuthorizedUserId()?.value != userId) { + return@withContext + } + + val fresh = + (runtime.client.getProfile(PeerId(0)) as? Outcome.Ok)?.value?.takeIf { it.id.value == userId } + ?: return@withContext + val updated = accountOperations.withLock { + if (accountRegistry.get(userId) == null || !runtime.isValid() || accountRuntimeManager.all() + .none { it === runtime } || runtime.status == AccountRuntimeStatus.SignInRequired || !runtime.sessionStore.isAuthorized() || runtime.sessionStore.readAuthorizedUserId()?.value != userId + ) { + return@withLock null + } + currentCoroutineContext().ensureActive() + runtime.sessionStore.upsertProfile(fresh) + val stored = runtime.sessionStore.readProfile(userId) + SettingsAccountDetails( + title = stored?.title?.takeIf { it.isNotBlank() }, + avatarCacheKey = stored?.avatarCacheKey, + mediaRepository = repository, + username = stored?.username, + phone = stored?.phone, + ) + } + currentCoroutineContext().ensureActive() + if (updated != null) publish(updated) + } + + suspend fun stopAccountWork(userId: Long) { + accountWorkManager.stop(userId) + } + + fun resumeActiveAccountWork() { + val userId = accountRegistry.activeUserId ?: return + settingsScope.launch { accountWorkManager.resume(userId) } } suspend fun awaitReady() = ready.await() + fun handleFcm( + payload: String?, + onComplete: (() -> Unit)? = null, + ) { + pushRouter.handleFcm(payload, onComplete) + } + fun awaitReadyBlocking(timeoutMs: Long = 20_000L): Boolean = runBlocking { try { withTimeout(timeoutMs) { ready.await() } @@ -140,12 +656,144 @@ class MonogramApp : Application() { } } + fun awaitPushReadyBlocking(timeoutMs: Long = 20_000L): Boolean = runBlocking { + try { + withTimeout(timeoutMs) { pushReady.await() } + true + } catch (_: Exception) { + false + } + } + fun launchWhenReady(block: suspend () -> Unit): Job = settingsScope.launch { ready.await() block() } + private fun createMediaRepository( + client: BridgedMtprotoClient, + root: File, + ): MediaRepository { + val repository = MediaRepository( + cacheRoot = root, + telegramChunkFetcher = object : TelegramChunkFetcher { + override suspend fun fetch( + chatId: PeerId, + messageId: Int, + destPath: String, + offset: Long, + ): Outcome = + client.downloadMessageMediaChunk(chatId, messageId, destPath, offset) + + override suspend fun fetchExpected( + chatId: PeerId, + messageId: Int, + destPath: String, + offset: Long, + expectedCacheKey: String, + ): Outcome = client.downloadMessageMediaChecked( + chatId, + messageId, + destPath, + expectedCacheKey, + MediaFetchKind.Full, + MediaPriority.VISIBLE, + offset, + ) + }, + telegramFetcher = object : TelegramMediaFetcher { + override suspend fun fetchExpectedMedia( + chatId: PeerId, + messageId: Int, + destPath: String, + kind: MediaFetchKind, + priority: Int, + expectedCacheKey: String, + ): Outcome = client.downloadMessageMediaChecked( + chatId, messageId, destPath, expectedCacheKey, kind, priority + ) + + override fun promote( + destPath: String, + priority: Int, + ) = client.promoteDownload(destPath, priority) + + override suspend fun fetchMessageMedia( + chatId: PeerId, + messageId: Int, + destPath: String, + kind: MediaFetchKind, + priority: Int, + ): Outcome = when (kind) { + MediaFetchKind.Thumb -> { + client.downloadMessageThumb( + chatId, + messageId, + destPath, + priority, + ) + } + + MediaFetchKind.Display -> { + client.downloadMessageDisplay( + chatId, + messageId, + destPath, + priority, + ) + } + + MediaFetchKind.Full -> { + client.downloadMessageMedia( + chatId, + messageId, + destPath, + priority, + ) + } + } + }, + customEmojiFetcher = { documentId, path, priority -> + client.downloadCustomEmoji(documentId, path, priority) + }, + inlineThumbPeek = TelegramInlineThumbPeek { chatId, messageId -> + client.peekMessageInlineThumb(chatId, messageId) + }, + peerInlineThumbPeek = TelegramPeerInlineThumbPeek { peerId -> + client.peekPeerInlineThumb(peerId) + }, + cacheLimitBytes = MediaCacheLimitStore.current.value.bytes, + ) + client.setDownloadProgressListener { path, downloaded, _ -> + repository.onNativeProgress(path, downloaded) + } + return repository + } + + private fun accountMedia(runtime: AccountRuntime): MediaRepository = + accountMediaRepositories.computeIfAbsent(runtime.account.userId) { + createMediaRepository(runtime.client, runtime.account.mediaPath) + } + + private fun leaseAccountMediaFile(file: File): AutoCloseable? = + acquireAccountMediaLease(file, accountMediaRepositories.values, bootstrapMediaRepository) + private fun applyDownloadSettings(state: DownloadState) { + if (::accountRuntimeManager.isInitialized) { + settingsScope.launch { + accountRuntimeManager.all().forEach { runtime -> + applyDownloadSettings(runtime.client, state) + } + } + } else { + applyDownloadSettings(client, state) + } + } + + private fun applyDownloadSettings( + client: BridgedMtprotoClient, + state: DownloadState, + ) { client.applyDownloadConcurrency(state.effectiveLanes, state.parts) client.setFilePartKib(state.filePartKib) client.setDownloadChunkKib(state.downloadChunkKib) @@ -156,6 +804,7 @@ class MonogramApp : Application() { super.onCreate() ComposeMaterial3Flags.isCheckboxStylingFixEnabled = true val startedAt = PerfLog.nowMs() + PerfLog.event("startup:begin") perfSpan("app:settings") { AppLog.init(cacheDir) } @@ -187,33 +836,54 @@ class MonogramApp : Application() { ready.complete(Unit) PerfLog.mark("app:ready", PerfLog.nowMs() - startedAt) settingsScope.launch { steps.mediaMigration() } - startAfterFirstPaint(steps) + startAfterFirstPaint(steps, settingsScope) + pushReady.complete(Unit) } catch (cancelled: CancellationException) { ready.completeExceptionally(cancelled) + pushReady.completeExceptionally(cancelled) throw cancelled } catch (error: Throwable) { AppLog.warn("startup", "init failed") ready.completeExceptionally(error) + pushReady.completeExceptionally(error) } } PerfLog.mark("app:onCreate", PerfLog.nowMs() - startedAt) } private fun firstPaintSteps(): StartupSteps { - val credentials = TelegramCredentials( + credentials = TelegramCredentials( apiId = BuildConfig.TELEGRAM_API_ID, apiHash = BuildConfig.TELEGRAM_API_HASH, ) val durableMedia = File(filesDir, "media") val cacheMedia = File(cacheDir, "media") + + fun currentAccount(): AccountRecord? = if (::accountRegistry.isInitialized) { + accountRegistry.activeUserId?.let(accountRegistry::get) + } else { + null + } + + fun currentMediaRoot(): File = currentAccount()?.mediaPath ?: durableMedia lateinit var db: MonogramDatabase + lateinit var startupWarmup: OfflineWarmup return StartupSteps( openDatabase = { - db = perfSpan("app:database") { DatabaseProvider.get(this) } + accountRegistry = AccountRegistry(filesDir).also { it.load() } + val account = accountRegistry.activeForStartup() + db = perfSpan("app:database") { + if (account == null || account.databaseName == "monogram.db") { + DatabaseProvider.get(this) + } else { + DatabaseProvider.get(this, account.userId) + } + } warmup = OfflineWarmup( - db, outgoingMediaFiles = org.monogram.core.database.OutgoingMediaFiles( - File(noBackupFilesDir, "outgoing-media") - ) + db, + outgoingMediaFiles = OutgoingMediaFiles( + File(noBackupFilesDir, "outgoing-media"), + ), ) sessionStore = SessionMetadataStore(db) }, @@ -225,40 +895,56 @@ class MonogramApp : Application() { }, createClient = { MtprotoNativeLoader.initializePlatformVerifier(this) - val sessionFile = File(filesDir, "mtproto.session.json") - perfSpan("app:tdlibImport") { - runCatching { - TdlibSessionImport.maybeImport( - filesDir, - sessionFile, - cacheDir - ) - }.onFailure { AppLog.warn("session", "tdlib import failed") } + val sessionFile = + currentAccount()?.sessionPath ?: File(filesDir, "mtproto.session.json") + if (currentAccount()?.databaseName == null || currentAccount()?.databaseName == "monogram.db") { + perfSpan("app:tdlibImport") { + runCatching { + TdlibSessionImport.maybeImport(filesDir, sessionFile, cacheDir) + }.onFailure { AppLog.warn("session", "tdlib import failed") } + } + } + accountRuntimeManager = AccountRuntimeManager( + context = this, + registry = accountRegistry, + credentials = credentials, + initConnection = monogramInitConnection(), + ) + val runtime = currentAccount()?.let { + accountRuntimeManager.switchTo(it.userId) } - client = perfSpan("app:client") { + client = runtime?.client ?: perfSpan("app:client") { BridgedMtprotoClient( credentials = credentials, sessionPath = sessionFile.absolutePath, initConnection = monogramInitConnection(), ) } + if (runtime != null) { + warmup = runtime.warmup + sessionStore = runtime.sessionStore + } ProxySettingsStore.load(this)?.let { saved -> val applied = runCatching { val transportMode = MtprotoTransportMode.valueOf(saved.transportMode.uppercase()) val proxyType = ProxyType.valueOf(saved.kind) - if (proxyType == ProxyType.NONE) false else client.setTransportMode( - transportMode - ) is Outcome.Ok && client.configureProxy( - ProxyConfig( - proxyType, - saved.host, - saved.port, - saved.username, - saved.password, - saved.secret - ) - ) is Outcome.Ok + if (proxyType == ProxyType.NONE) { + false + } else { + client.setTransportMode( + transportMode, + ) is Outcome.Ok && client.configureProxy( + ProxyConfig( + proxyType, + saved.host, + saved.port, + saved.username, + saved.password, + saved.secret, + ), + ) is Outcome.Ok + } }.getOrDefault(false) if (!applied) { runCatching { client.clearProxy() } @@ -268,49 +954,82 @@ class MonogramApp : Application() { AppLog.warn("proxy", "stored proxy settings rejected") } } + startupWarmup = warmup }, installImageCache = { perfSpan("app:imageCache") { ImageCache.install(this) } }, cleanup = { - runCatching { warmup.prepareStartupSnapshot(sessionStore) }.onFailure { - AppLog.warn( - "warmup", - "startup cleanup failed" - ) + sessionStore.readAuthorizedUserId()?.let { userId -> + check(currentAccount()?.userId?.let { it == userId.value } != false) { + "Account registry and database identity differ" + } + val dcId = sessionStore.readMeta(SessionMetadataStore.KEY_DC_ID)?.toIntOrNull() + if (dcId != null) { + val account = accountRegistry.adoptLegacy(AuthSession(userId, dcId)) + accountRuntimeManager.adopt( + account = account, + client = client, + database = db, + sessionStore = sessionStore, + warmup = warmup, + ) + } + } + try { + startupWarmup.prepareStartupSnapshot(sessionStore) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("warmup", "startup cleanup failed") + } + }, + maintainCache = { + val started = PerfLog.nowMs() + try { + startupWarmup.pruneExpiredCache(force = true) + } finally { + PerfLog.mark("cache:startupPrune", PerfLog.nowMs() - started) } }, prewarm = { - if (BuildConfig.STARTUP_PREWARM) { + if (BuildConfig.STARTUP_PREWARM && sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) != "0") { val started = PerfLog.nowMs() when (val result = runCatching { client.connect() }.getOrNull()) { - is Outcome.Ok -> PerfLog.mark( - "app:prewarm", - PerfLog.nowMs() - started, - "result=ok" - ) + is Outcome.Ok -> { + PerfLog.mark( + "app:prewarm", + PerfLog.nowMs() - started, + "result=ok", + ) + } - is Outcome.Err -> PerfLog.mark( - "app:prewarm", - PerfLog.nowMs() - started, - "result=err" - ) + is Outcome.Err -> { + PerfLog.mark( + "app:prewarm", + PerfLog.nowMs() - started, + "result=err", + ) + } - null -> PerfLog.mark( - "app:prewarm", - PerfLog.nowMs() - started, - "result=throw" - ) + null -> { + PerfLog.mark( + "app:prewarm", + PerfLog.nowMs() - started, + "result=throw", + ) + } } } }, mediaMigration = { TdlibSessionImport.wipeTdlib(filesDir, cacheDir) - if (!durableMedia.exists() && cacheMedia.exists()) { + val durableMedia = currentMediaRoot() + if ((currentAccount() == null || currentAccount()?.databaseName == "monogram.db") && !durableMedia.exists() && cacheMedia.exists()) { runCatching { cacheMedia.copyRecursively( durableMedia, - overwrite = false + overwrite = false, ) }.onFailure { AppLog.warn("media", "cache migration failed") } } @@ -323,65 +1042,36 @@ class MonogramApp : Application() { } }, createMedia = { + val durableMedia = currentMediaRoot() + val client = client + val warmup = warmup mediaRepository = perfSpan("app:mediaRepository") { - MediaRepository( - cacheRoot = durableMedia, - telegramChunkFetcher = TelegramChunkFetcher { chatId, messageId, destPath, offset -> - client.downloadMessageMediaChunk(chatId, messageId, destPath, offset) - }, - telegramFetcher = object : TelegramMediaFetcher { - override fun promote(destPath: String, priority: Int) = - client.promoteDownload(destPath, priority) - - override suspend fun fetchMessageMedia( - chatId: PeerId, - messageId: Int, - destPath: String, - kind: MediaFetchKind, - priority: Int, - ): Outcome = when (kind) { - MediaFetchKind.Thumb -> client.downloadMessageThumb( - chatId, messageId, destPath, priority - ) - - MediaFetchKind.Display -> client.downloadMessageDisplay( - chatId, messageId, destPath, priority - ) - - MediaFetchKind.Full -> client.downloadMessageMedia( - chatId, messageId, destPath, priority - ) - } - }, - - customEmojiFetcher = { documentId, destPath, priority -> - client.downloadCustomEmoji(documentId, destPath, priority) - }, - inlineThumbPeek = TelegramInlineThumbPeek { chatId, messageId -> - client.peekMessageInlineThumb(chatId, messageId) - }, - peerInlineThumbPeek = TelegramPeerInlineThumbPeek { peerId -> - client.peekPeerInlineThumb(peerId) - }, - cacheLimitBytes = MediaCacheLimitStore.current.value.bytes, - ) + createMediaRepository(client, durableMedia) } - FileLeases.leaser = mediaRepository::leaseFile - client.setDownloadProgressListener { path, downloaded, _ -> - mediaRepository.onNativeProgress(path, downloaded) + val accountId = currentAccount()?.userId + if (accountId != null) { + accountMediaRepositories[accountId] = mediaRepository + } else { + bootstrapMediaRepository = mediaRepository } + val repository = mediaRepository + FileLeases.leaser = ::leaseAccountMediaFile settingsScope.launch { ready.await() warmup.prepareRecentHistory() warmup.recentHistory.warmedMessages().forEach { message -> if (!message.thumbCacheKey.isNullOrBlank()) { - mediaRepository.ensureLocalMessageThumb(message, MediaPriority.THUMB) + repository.ensureLocalMessageThumb(message, MediaPriority.THUMB) } } } }, createPush = { - notifications = NotificationLocalStore(this) + notifications = NotificationLocalStore( + this, + currentAccount()?.userId, + migrateLegacy = currentAccount()?.databaseName == "monogram.db", + ) push = perfSpan("app:push") { PushCoordinator( this, @@ -390,8 +1080,20 @@ class MonogramApp : Application() { mediaRepository, DefaultStoreFactory(), sessionStore, + otherAccountUserIds = { + accountRegistry.all().map { it.userId } + }, + sharedPushSecret = sharedPushSecret::get, + notificationCache = warmup, ) } + pushRouter = AccountPushRouter() + currentAccount()?.userId?.let { userId -> + push.setAccountUserId(userId) + pushRouter.register(userId, push) + accountPushes[userId] = push + pushRouterCoordinatorIds += userId + } sponsorSync = SponsorSyncManager( settingsScope, client, @@ -401,14 +1103,13 @@ class MonogramApp : Application() { appUpdate = AppUpdateManager( scope = settingsScope, isAuthorized = { - if (sessionStore.isAuthorized()) { - true - } else { - when (val result = client.isLocallyAuthorized()) { - is Outcome.Ok -> result.value - is Outcome.Err -> false - } - } + resolveStartOnHome( + sessionAuthorized = { sessionStore.isAuthorized() }, + locallyAuthorized = { client.isLocallyAuthorized() }, + explicitlySignedOut = { + sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0" + }, + ) }, betaUpdates = { AppUpdateSettings.betaUpdates.value }, isSupporter = { @@ -444,15 +1145,62 @@ class MonogramApp : Application() { runCatching { push.start() }.onFailure { AppLog.warn( "startup", - "push start failed" + "push start failed", ) } }, + startAccounts = { + try { + val readyUserIds = accountOperations.withLock { + val ready = prepareStartupAccounts( + accounts = accountRegistry.all(), + activeUserId = accountRegistry.activeUserId, + onUnavailable = { + AppLog.warn( + "startup", + "account storage unavailable", + ) + }, + ) { account -> + val runtime = accountRuntimeManager.open(account) + runtime.client.installDurableUpdateSink( + AccountDurableUpdateSink(runtime.warmup)::commit, + ) + } + startAccountPushes(ready) + pushReady.complete(Unit) + ready + } + accountRuntimeManager.startAll(readyUserIds) + accountOperations.withLock { + for (runtime in accountRuntimeManager.all()) { + if (runtime.account.userId !in readyUserIds) continue + try { + if (runtime.sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0") continue + if (runtime.client.isLocallyAuthorized() == Outcome.Ok(true)) { + accountWorkManager.start(runtime) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("startup", "account background work unavailable") + } + } + applyDownloadSettings(DownloadSettings.state.value) + startAccountPushes(readyUserIds) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + AppLog.warn("startup", "account clients start failed") + throw error + } + }, startSponsor = { runCatching { sponsorSync.start() }.onFailure { AppLog.warn( "startup", - "sponsor start failed" + "sponsor start failed", ) } }, @@ -460,7 +1208,7 @@ class MonogramApp : Application() { runCatching { appUpdate.start() }.onFailure { AppLog.warn( "startup", - "app update start failed" + "app update start failed", ) } }, @@ -482,6 +1230,15 @@ class MonogramApp : Application() { } } +internal fun acquireAccountMediaLease( + file: File, + repositories: Collection, + bootstrap: MediaRepository?, +): AutoCloseable? = + repositories.firstNotNullOfOrNull { it.leaseFile(file) } ?: bootstrap?.leaseFile(file) + +internal fun canRemoveAccount(accountCount: Int): Boolean = accountCount > 1 + private fun initDeviceModel(): String { val manufacturer = Build.MANUFACTURER.orEmpty().trim() val model = Build.MODEL.orEmpty().trim() @@ -500,15 +1257,15 @@ private fun isEmulatorDevice(model: String): Boolean { val hardware = Build.HARDWARE.orEmpty() return fingerprint.startsWith("generic") || fingerprint.contains( "emulator", - ignoreCase = true + ignoreCase = true, ) || product.contains("sdk", ignoreCase = true) || hardware.contains( "goldfish", - ignoreCase = true + ignoreCase = true, ) || hardware.contains("ranchu", ignoreCase = true) || model.contains( "sdk_gphone", - ignoreCase = true + ignoreCase = true, ) || model.contains("emulator", ignoreCase = true) || model.contains( "google_sdk", - ignoreCase = true + ignoreCase = true, ) } diff --git a/app/src/main/java/org/monogram/StartupAuth.kt b/app/src/main/java/org/monogram/StartupAuth.kt index 13266634e..85e9db251 100644 --- a/app/src/main/java/org/monogram/StartupAuth.kt +++ b/app/src/main/java/org/monogram/StartupAuth.kt @@ -5,7 +5,9 @@ import org.monogram.core.common.Outcome internal suspend fun resolveStartOnHome( sessionAuthorized: suspend () -> Boolean, locallyAuthorized: suspend () -> Outcome, + explicitlySignedOut: suspend () -> Boolean = { false }, ): Boolean { + if (explicitlySignedOut()) return false if (sessionAuthorized()) return true return when (val local = locallyAuthorized()) { is Outcome.Ok -> local.value diff --git a/app/src/main/java/org/monogram/StartupSequence.kt b/app/src/main/java/org/monogram/StartupSequence.kt index 856e6d363..273342d00 100644 --- a/app/src/main/java/org/monogram/StartupSequence.kt +++ b/app/src/main/java/org/monogram/StartupSequence.kt @@ -1,5 +1,6 @@ package org.monogram +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -13,10 +14,12 @@ internal data class StartupSteps( val cleanup: suspend () -> Unit, val prewarm: suspend () -> Unit, val mediaMigration: suspend () -> Unit, + val maintainCache: suspend () -> Unit = {}, val installDownloadSettings: suspend () -> Unit, val createMedia: suspend () -> Unit, val createPush: suspend () -> Unit, val startPush: () -> Unit = {}, + val startAccounts: suspend () -> Unit = {}, val startSponsor: () -> Unit = {}, val startAppUpdate: () -> Unit = {}, ) @@ -28,11 +31,9 @@ internal suspend fun runFirstPaintStartup( coroutineScope { val images = async { steps.installImageCache() } val appearance = async { steps.installAppearance() } - val database = async { steps.openDatabase() } - val client = async { steps.createClient() } - database.await() + steps.openDatabase() + steps.createClient() steps.cleanup() - client.await() background.launch { steps.prewarm() } steps.installDownloadSettings() @@ -43,8 +44,28 @@ internal suspend fun runFirstPaintStartup( } } -internal fun startAfterFirstPaint(steps: StartupSteps) { +internal suspend fun startAfterFirstPaint(steps: StartupSteps, background: CoroutineScope) { + background.launch { steps.maintainCache() } steps.startPush() + steps.startAccounts() steps.startSponsor() steps.startAppUpdate() -} \ No newline at end of file +} + +internal suspend fun prepareStartupAccounts( + accounts: List, + activeUserId: Long?, + onUnavailable: () -> Unit, + prepare: suspend (AccountRecord) -> Unit, +): Set = accounts.mapNotNull { account -> + try { + prepare(account) + account.userId + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + if (account.userId == activeUserId) throw error + onUnavailable() + null + } +}.toSet() diff --git a/app/src/main/java/org/monogram/push/AccountPushHandler.kt b/app/src/main/java/org/monogram/push/AccountPushHandler.kt new file mode 100644 index 000000000..08ef5d3bc --- /dev/null +++ b/app/src/main/java/org/monogram/push/AccountPushHandler.kt @@ -0,0 +1,14 @@ +package org.monogram.push + +import org.monogram.core.common.Outcome + +interface AccountPushHandler { + fun setForeground(value: Boolean) + suspend fun unregisterPush() + fun stop() + fun onFcmToken(token: String, onComplete: (() -> Unit)? = null) + fun handleFcm(payload: String?, onComplete: (() -> Unit)? = null) + fun decryptFcmPayload(cipher: String): Outcome + fun handleDecryptedFcmPayload(json: String, onComplete: (() -> Unit)? = null) + fun handleNotificationAction(intent: android.content.Intent, done: () -> Unit) +} \ No newline at end of file diff --git a/app/src/main/java/org/monogram/push/AccountPushRouter.kt b/app/src/main/java/org/monogram/push/AccountPushRouter.kt new file mode 100644 index 000000000..023c4cb70 --- /dev/null +++ b/app/src/main/java/org/monogram/push/AccountPushRouter.kt @@ -0,0 +1,145 @@ +package org.monogram.push + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import org.monogram.core.common.Outcome +import org.monogram.core.common.push.IncomingPush +import org.monogram.core.common.push.normalizeDecrypted +import org.monogram.core.common.push.normalizeIncomingBody +import org.monogram.core.common.push.parsePushPayload +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume + +/** Routes encrypted push payloads to the account whose secret and user id own them. */ +class AccountPushRouter { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val coordinators = ConcurrentHashMap() + + @Volatile + private var foreground = false + + @Volatile + private var foregroundUserId: Long? = null + + @Synchronized + fun register(accountUserId: Long, coordinator: AccountPushHandler) { + if (accountUserId != 0L) { + coordinators[accountUserId] = coordinator + coordinator.setForeground( + isAccountForeground( + foreground, foregroundUserId, accountUserId + ) + ) + } + } + + suspend fun unregister(accountUserId: Long) { + coordinators.remove(accountUserId)?.let { coordinator -> + try { + coordinator.unregisterPush() + } finally { + coordinator.stop() + } + } + } + + fun coordinator(accountUserId: Long): AccountPushHandler? = coordinators[accountUserId] + + @Synchronized + fun setForeground(foreground: Boolean, activeUserId: Long?) { + this.foreground = foreground + foregroundUserId = activeUserId + coordinators.forEach { (userId, coordinator) -> + coordinator.setForeground(isAccountForeground(foreground, activeUserId, userId)) + } + } + + fun onFcmToken(token: String, onComplete: () -> Unit) { + scope.launch { + try { + coordinators.values.toList().forEach { coordinator -> + suspendCancellableCoroutine { continuation -> + coordinator.onFcmToken(token) { + if (continuation.isActive) continuation.resume(Unit) + } + } + } + } finally { + onComplete() + } + } + } + + fun handleFcm(payload: String?, onComplete: (() -> Unit)? = null) { + scope.launch { + try { + when (val incoming = normalizeIncomingBody(true, payload?.encodeToByteArray())) { + IncomingPush.Wake -> wakeAll() + is IncomingPush.Decrypt -> routeEncrypted(incoming.cipher) + is IncomingPush.Present -> routePlain(incoming.json) + } + } finally { + onComplete?.invoke() + } + } + } + + private suspend fun routeEncrypted(cipher: String) { + val candidates = coordinators.entries.toList() + for ((userId, coordinator) in candidates) { + when (val decrypted = coordinator.decryptFcmPayload(cipher)) { + is Outcome.Ok -> when (val plain = normalizeDecrypted(decrypted.value)) { + is IncomingPush.Present -> { + val payload = parsePushPayload(plain.json) + if (payload.userId == userId) { + awaitCoordinator(coordinator, plain.json) + return + } + } + + IncomingPush.Wake -> { + wakeAll() + return + } + + is IncomingPush.Decrypt -> Unit + } + + is Outcome.Err -> Unit + } + } + } + + private suspend fun routePlain(json: String) { + val userId = parsePushPayload(json).userId ?: return + coordinators[userId]?.let { coordinator -> + awaitCoordinator(coordinator, json) + } + } + + private suspend fun awaitCoordinator(coordinator: AccountPushHandler, json: String) = + suspendCancellableCoroutine { continuation -> + coordinator.handleDecryptedFcmPayload(json) { + if (continuation.isActive) continuation.resume(Unit) + } + } + + private suspend fun wakeAll() { + coordinators.values.toList().forEach { coordinator -> + suspendCancellableCoroutine { continuation -> + coordinator.handleFcm(null) { + if (continuation.isActive) continuation.resume(Unit) + } + } + } + } +} + +internal fun isAccountForeground( + appForeground: Boolean, + activeUserId: Long?, + accountUserId: Long, +): Boolean = appForeground && activeUserId == accountUserId diff --git a/app/src/main/java/org/monogram/push/MonogramFirebaseMessagingService.kt b/app/src/main/java/org/monogram/push/MonogramFirebaseMessagingService.kt index 6d168c61f..522e9f2df 100644 --- a/app/src/main/java/org/monogram/push/MonogramFirebaseMessagingService.kt +++ b/app/src/main/java/org/monogram/push/MonogramFirebaseMessagingService.kt @@ -10,16 +10,18 @@ import java.util.concurrent.TimeUnit class MonogramFirebaseMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { val app = application as? MonogramApp ?: return - if (!app.awaitReadyBlocking()) return - app.push.onFcmToken(token) + if (!app.awaitPushReadyBlocking()) return + val latch = CountDownLatch(1) + app.pushRouter.onFcmToken(token) { latch.countDown() } + latch.await(20, TimeUnit.SECONDS) } override fun onMessageReceived(message: RemoteMessage) { val app = application as? MonogramApp ?: return - if (!app.awaitReadyBlocking()) return + if (!app.awaitPushReadyBlocking()) return AppLog.api("fcm", "data keys=${message.data.keys.joinToString()}") val latch = CountDownLatch(1) - app.push.handleFcm(message.data["p"]) { latch.countDown() } + app.handleFcm(message.data["p"]) { latch.countDown() } latch.await(20, TimeUnit.SECONDS) } } diff --git a/app/src/main/java/org/monogram/push/MonogramUnifiedPushService.kt b/app/src/main/java/org/monogram/push/MonogramUnifiedPushService.kt index fd3049925..3b868b4dc 100644 --- a/app/src/main/java/org/monogram/push/MonogramUnifiedPushService.kt +++ b/app/src/main/java/org/monogram/push/MonogramUnifiedPushService.kt @@ -2,11 +2,6 @@ package org.monogram.push import org.monogram.MonogramApp import org.monogram.core.common.AppLog -import org.monogram.core.common.push.NO_PUSH_STATUS -import org.monogram.core.common.push.PUSH_STATUS_DISTRIBUTOR_GONE -import org.monogram.core.common.push.PUSH_STATUS_UP_FAILED -import org.monogram.core.common.push.PushProviderMode -import org.monogram.core.models.PushTokenType import org.unifiedpush.android.connector.FailedReason import org.unifiedpush.android.connector.PushService import org.unifiedpush.android.connector.data.PushEndpoint @@ -15,39 +10,36 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit class MonogramUnifiedPushService : PushService() { + private fun coordinator(instance: String): PushCoordinator? { + val app = application as? MonogramApp ?: return null + if (!app.awaitPushReadyBlocking()) return null + val userId = instance.removePrefix("account:").toLongOrNull() ?: return null + if (userId == 0L || instance != "account:$userId") return null + val coordinator = app.pushRouter.coordinator(userId) as? PushCoordinator ?: return null + return coordinator.takeIf { it.ownsUnifiedPushInstance(instance) } + } + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { - val app = application as? MonogramApp ?: return - if (!app.awaitReadyBlocking()) return + val coordinator = coordinator(instance) ?: return val keys = endpoint.pubKeySet - app.push.onWebPushEndpoint(endpoint.url, keys?.pubKey, keys?.auth) + coordinator.onWebPushEndpoint(endpoint.url, keys?.pubKey, keys?.auth) } override fun onMessage(message: PushMessage, instance: String) { - val app = application as? MonogramApp ?: return - if (!app.awaitReadyBlocking()) return + val coordinator = coordinator(instance) ?: return AppLog.api("unifiedpush", "wake") val latch = CountDownLatch(1) - app.push.handleIncoming(fcm = false, message.content) { latch.countDown() } + coordinator.handleIncoming(fcm = false, message.content) { latch.countDown() } latch.await(20, TimeUnit.SECONDS) } override fun onRegistrationFailed(reason: FailedReason, instance: String) { + val coordinator = coordinator(instance) ?: return AppLog.warn("unifiedpush", "registration failed") - val app = application as? MonogramApp ?: return - if (!app.awaitReadyBlocking()) return - app.notifications.setLastRegister(PUSH_STATUS_UP_FAILED) + coordinator.onUnifiedPushRegistrationFailed() } override fun onUnregistered(instance: String) { - val app = application as? MonogramApp ?: return - if (!app.awaitReadyBlocking()) return - val type = app.notifications.tokenType() - val wasUnifiedPush = type == PushTokenType.Simple.code || type == PushTokenType.WebPush.code - if (!wasUnifiedPush) return - val mode = app.notifications.providerMode - app.notifications.clearPushIdentity() - app.notifications.setLastRegister( - if (mode == PushProviderMode.ForceUnifiedPush) PUSH_STATUS_DISTRIBUTOR_GONE else NO_PUSH_STATUS, - ) + coordinator(instance)?.onUnifiedPushUnregistered() } } diff --git a/app/src/main/java/org/monogram/push/NotificationActionReceiver.kt b/app/src/main/java/org/monogram/push/NotificationActionReceiver.kt index d087a4db2..68b0f7624 100644 --- a/app/src/main/java/org/monogram/push/NotificationActionReceiver.kt +++ b/app/src/main/java/org/monogram/push/NotificationActionReceiver.kt @@ -11,7 +11,11 @@ class NotificationActionReceiver : BroadcastReceiver() { val pending = goAsync() app.launchWhenReady { try { - app.push.handleNotificationAction(intent) { pending.finish() } + val accountId = intent.getLongExtra(NotificationPresenter.EXTRA_ACCOUNT_USER_ID, 0L) + val owner = notificationActionOwner(accountId, app.accountRegistry.all()) + val coordinator = owner?.let(app.pushRouter::coordinator) + if (coordinator == null) pending.finish() + else coordinator.handleNotificationAction(intent) { pending.finish() } } catch (_: Throwable) { pending.finish() } @@ -20,3 +24,27 @@ class NotificationActionReceiver : BroadcastReceiver() { } } } + +internal sealed interface NotificationAccountRoute { + data object Reject : NotificationAccountRoute + data object Open : NotificationAccountRoute + data class Switch(val userId: Long) : NotificationAccountRoute +} + +internal fun notificationAccountRoute( + accountId: Long, + activeUserId: Long?, + accounts: List, +): NotificationAccountRoute { + val owner = + notificationActionOwner(accountId, accounts) ?: return NotificationAccountRoute.Reject + return if (owner == activeUserId) NotificationAccountRoute.Open + else NotificationAccountRoute.Switch(owner) +} + +internal fun notificationActionOwner( + accountId: Long, accounts: List +): Long? = if (accountId > 0L) accounts.firstOrNull { it.userId == accountId }?.userId +else if (accountId == 0L) accounts.singleOrNull() + ?.takeIf { it.databaseName == "monogram.db" }?.userId +else null diff --git a/app/src/main/java/org/monogram/push/NotificationChannels.kt b/app/src/main/java/org/monogram/push/NotificationChannels.kt index 22ce6ae37..8598b1d7a 100644 --- a/app/src/main/java/org/monogram/push/NotificationChannels.kt +++ b/app/src/main/java/org/monogram/push/NotificationChannels.kt @@ -10,6 +10,8 @@ import org.monogram.core.common.push.NotificationChannelPlan import org.monogram.core.common.push.NotificationLocalStore import org.monogram.core.common.push.PushChannelKind import org.monogram.core.models.Folder +import org.monogram.push.NotificationChannels.applyPrefs +import org.monogram.push.NotificationChannels.channelFor object NotificationChannels { private const val FOLDER_PREFIX = "folder" @@ -30,44 +32,84 @@ object NotificationChannels { const val GROUP_STORIES = "stories" const val GROUP_REACTIONS = "reactions" - /** - * @param pruneFolders when true, `folders` is treated as the authoritative server list and - * channels of deleted folders are removed. Only call it with a freshly fetched folder list: - * the no-arg call must never prune. - */ - fun ensure(context: Context, folders: List = emptyList(), pruneFolders: Boolean = false) { + fun ensure(context: Context, folders: List = emptyList()) { if (Build.VERSION.SDK_INT < 26) return val manager = context.getSystemService(NotificationManager::class.java) ?: return // One channel group per category, mirroring Telegram's `ensureGroupsCreated` layout. - manager.createNotificationChannelGroup(NotificationChannelGroup(GROUP_PRIVATE, context.getString(org.monogram.R.string.push_channel_private))) - manager.createNotificationChannelGroup(NotificationChannelGroup(GROUP_GROUPS, context.getString(org.monogram.R.string.push_channel_groups))) - manager.createNotificationChannelGroup(NotificationChannelGroup(GROUP_CHANNELS, context.getString(org.monogram.R.string.push_channel_channels))) - manager.createNotificationChannelGroup(NotificationChannelGroup(GROUP_OTHER, context.getString(org.monogram.R.string.push_channel_other))) - manager.createNotificationChannelGroup(NotificationChannelGroup(GROUP_FOLDERS, context.getString(org.monogram.R.string.push_group_folders))) - manager.createNotificationChannelGroup(NotificationChannelGroup(GROUP_STORIES, context.getString(org.monogram.R.string.push_group_stories))) - manager.createNotificationChannelGroup(NotificationChannelGroup(GROUP_REACTIONS, context.getString(org.monogram.R.string.push_group_reactions))) - ensureChannel(manager, context, PRIVATE, org.monogram.R.string.push_channel_private, GROUP_PRIVATE) - ensureChannel(manager, context, GROUPS, org.monogram.R.string.push_channel_groups, GROUP_GROUPS) - ensureChannel(manager, context, CHANNELS, org.monogram.R.string.push_channel_channels, GROUP_CHANNELS) - ensureChannel(manager, context, STORIES, org.monogram.R.string.push_channel_stories, GROUP_STORIES) - ensureChannel(manager, context, REACTIONS, org.monogram.R.string.push_channel_reactions, GROUP_REACTIONS) - ensureChannel(manager, context, OTHER, org.monogram.R.string.push_channel_other, GROUP_OTHER) - ensureChannel(manager, context, CALLS, org.monogram.R.string.push_channel_calls, GROUP_OTHER) + manager.createNotificationChannelGroup( + NotificationChannelGroup( + GROUP_PRIVATE, context.getString(org.monogram.R.string.push_channel_private) + ) + ) + manager.createNotificationChannelGroup( + NotificationChannelGroup( + GROUP_GROUPS, context.getString(org.monogram.R.string.push_channel_groups) + ) + ) + manager.createNotificationChannelGroup( + NotificationChannelGroup( + GROUP_CHANNELS, context.getString(org.monogram.R.string.push_channel_channels) + ) + ) + manager.createNotificationChannelGroup( + NotificationChannelGroup( + GROUP_OTHER, context.getString(org.monogram.R.string.push_channel_other) + ) + ) + manager.createNotificationChannelGroup( + NotificationChannelGroup( + GROUP_FOLDERS, context.getString(org.monogram.R.string.push_group_folders) + ) + ) + manager.createNotificationChannelGroup( + NotificationChannelGroup( + GROUP_STORIES, context.getString(org.monogram.R.string.push_group_stories) + ) + ) + manager.createNotificationChannelGroup( + NotificationChannelGroup( + GROUP_REACTIONS, context.getString(org.monogram.R.string.push_group_reactions) + ) + ) + ensureChannel( + manager, context, PRIVATE, org.monogram.R.string.push_channel_private, GROUP_PRIVATE + ) + ensureChannel( + manager, context, GROUPS, org.monogram.R.string.push_channel_groups, GROUP_GROUPS + ) + ensureChannel( + manager, context, CHANNELS, org.monogram.R.string.push_channel_channels, GROUP_CHANNELS + ) + ensureChannel( + manager, context, STORIES, org.monogram.R.string.push_channel_stories, GROUP_STORIES + ) + ensureChannel( + manager, + context, + REACTIONS, + org.monogram.R.string.push_channel_reactions, + GROUP_REACTIONS + ) + ensureChannel( + manager, context, OTHER, org.monogram.R.string.push_channel_other, GROUP_OTHER + ) + ensureChannel( + manager, context, CALLS, org.monogram.R.string.push_channel_calls, GROUP_OTHER + ) folders.forEach { folder -> ensureChannel( manager, context, folderChannel(folder.id), - folder.title.ifBlank { context.getString(org.monogram.R.string.push_folder, folder.id) }, + folder.title.ifBlank { + context.getString( + org.monogram.R.string.push_folder, folder.id + ) + }, GROUP_FOLDERS, ) } - if (pruneFolders) { - val live = folders.mapTo(HashSet()) { it.id } - manager.notificationChannels - .filter { it.id.startsWith(FOLDER_PREFIX + "_") && folderIdOf(it.id) !in live } - .forEach { manager.deleteNotificationChannel(it.id) } - } + // Folder channel IDs are shared across accounts; one account cannot safely prune them. } /** @@ -116,11 +158,12 @@ object NotificationChannels { custom: Boolean, sound: Boolean, popup: Boolean, + store: NotificationLocalStore, ): String { val base = idFor(kind, folderId) // Vibration/LED of a folder cannot be applied to an existing channel either, so they pick // the channel id: a preference change simply routes to a fresh channel. - val suffix = folderPreferenceSuffix(context, folderId) + suffix(sound, popup) + val suffix = folderPreferenceSuffix(store, folderId) + suffix(sound, popup) val customBase = if (custom && chatId != 0L) "${base}_c$chatId" else base val id = customBase + suffix if (id == base || Build.VERSION.SDK_INT < 26) return id @@ -136,8 +179,8 @@ object NotificationChannels { }, groupFor(kind, folderId), ) - val folderVibrate = folderId == null || preference(context, folderId, "vibrate") - val folderLed = folderId == null || preference(context, folderId, "led") + val folderVibrate = folderId == null || preference(store, folderId, "vibrate") + val folderLed = folderId == null || preference(store, folderId, "led") channel.importance = importanceFor(sound, popup) if (sound) { channel.enableVibration(folderVibrate) @@ -152,18 +195,18 @@ object NotificationChannels { return id } - private fun suffix(sound: Boolean, popup: Boolean): String = NotificationChannelPlan.suffix(sound, popup) + private fun suffix(sound: Boolean, popup: Boolean): String = + NotificationChannelPlan.suffix(sound, popup) /** Suffix that encodes the folder's vibration/LED choice; empty for the defaults. */ - private fun folderPreferenceSuffix(context: Context, folderId: Int?): String { + private fun folderPreferenceSuffix(store: NotificationLocalStore, folderId: Int?): String { if (folderId == null) return "" - val vibrate = preference(context, folderId, "vibrate") - val led = preference(context, folderId, "led") + val vibrate = preference(store, folderId, "vibrate") + val led = preference(store, folderId, "led") return if (vibrate && led) "" else "_v${if (vibrate) 1 else 0}l${if (led) 1 else 0}" } - private fun preference(context: Context, folderId: Int, key: String): Boolean { - val store = NotificationLocalStore(context) + private fun preference(store: NotificationLocalStore, folderId: Int, key: String): Boolean { val name = "folder_$folderId" return when (key) { "vibrate" -> store.categoryVibrate(name) @@ -238,11 +281,16 @@ object NotificationChannels { applyKind(context, REACTIONS, store, "reactions") // Folder channels keep their own preference key so a folder can differ from its categories. val manager = context.getSystemService(NotificationManager::class.java) ?: return - manager.notificationChannels - .filter { it.id.startsWith(FOLDER_PREFIX + "_") } + manager.notificationChannels.filter { it.id.startsWith(FOLDER_PREFIX + "_") } // Preference key is the folder, never the channel id: a variant like `folder_2_v0l1` // would otherwise read defaults and re-enable the vibration the user switched off. - .forEach { channel -> folderIdOf(channel.id)?.let { applyKind(context, channel.id, store, "$FOLDER_PREFIX" + "_" + it) } } + .forEach { channel -> + folderIdOf(channel.id)?.let { + applyKind( + context, channel.id, store, "$FOLDER_PREFIX" + "_" + it + ) + } + } } /** @@ -251,7 +299,9 @@ object NotificationChannels { * it when the app deletes and recreates the same id), so heads-up behaviour is expressed by * channel variants in [channelFor] instead. */ - private fun applyKind(context: Context, id: String, store: NotificationLocalStore, kind: String) { + private fun applyKind( + context: Context, id: String, store: NotificationLocalStore, kind: String + ) { if (Build.VERSION.SDK_INT < 26) return val manager = context.getSystemService(NotificationManager::class.java) ?: return val existing = manager.getNotificationChannel(id) ?: return @@ -268,7 +318,9 @@ object NotificationChannels { } @androidx.annotation.RequiresApi(Build.VERSION_CODES.O) - private fun channel(context: Context, id: String, title: Any, group: String): NotificationChannel { + private fun channel( + context: Context, id: String, title: Any, group: String + ): NotificationChannel { val name = when (title) { is Int -> context.getString(title) else -> title.toString() diff --git a/app/src/main/java/org/monogram/push/NotificationPresenter.kt b/app/src/main/java/org/monogram/push/NotificationPresenter.kt index 2a8a4c2b2..f7b74f4e8 100644 --- a/app/src/main/java/org/monogram/push/NotificationPresenter.kt +++ b/app/src/main/java/org/monogram/push/NotificationPresenter.kt @@ -51,6 +51,9 @@ import org.monogram.core.common.push.shouldAutoExpandBubble import org.monogram.core.common.push.shouldRetryPostWithoutBubble import java.io.File +internal fun isLegacyNotification(owner: Long, child: Boolean, legacySummary: Boolean): Boolean = + owner <= 0L && (child || legacySummary) + object NotificationPresenter { const val ACTION_OPEN_CHAT = "org.monogram.push.OPEN_CHAT" const val ACTION_REPLY = "org.monogram.push.REPLY" @@ -63,11 +66,14 @@ object NotificationPresenter { const val EXTRA_CHAT_ID = "chat_id" const val EXTRA_MESSAGE_ID = "monogram_message_id" const val EXTRA_MAX_ID = "max_id" + const val EXTRA_ACCOUNT_USER_ID = "account_user_id" const val KEY_TEXT_REPLY = "push_reply_text" - /** Every chat notification joins this group so a burst collapses into one stack in the shade. */ const val GROUP_KEY = NotificationConversation.GROUP_KEY + private fun groupKey(accountUserId: Long?): String = + accountUserId?.let { "$GROUP_KEY.account.$it" } ?: GROUP_KEY + /** Chat ids keep the sign bit clear, so the group summary can never collide with a chat id. */ private const val SUMMARY_ID = Int.MIN_VALUE @@ -89,11 +95,37 @@ object NotificationPresenter { private const val HIDDEN_MESSAGE_ID = -1 private const val EXTRA_CHILD = "org.monogram.push.child" - private var summaryPosted = false + private val summariesPosted = mutableSetOf() private const val EXTRA_UNREAD_COUNT = "org.monogram.push.unread_count" private const val EXTRA_REVISION = "org.monogram.push.revision" private val pendingPosts = mutableMapOf>() + @Synchronized + internal fun clearLegacyNotifications(context: Context) { + val prefs = context.getSharedPreferences("notification_migrations", Context.MODE_PRIVATE) + if (prefs.getBoolean("account_scoped_shade", false)) return + try { + val manager = NotificationManagerCompat.from(context) + val old = manager.activeNotifications.filter { active -> + active.tag == null && isLegacyNotification( + active.notification.extras.getLong(EXTRA_ACCOUNT_USER_ID, 0L), + active.notification.extras.getBoolean(EXTRA_CHILD), + active.id == SUMMARY_ID && active.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0, + ) + } + old.forEach { + manager.cancel(it.id) + pendingPosts.remove(it.id) + lastAlertAt.remove(it.id) + } + removeShareShortcuts(context, NotificationLocalStore(context).shareChatIds) + summariesPosted.remove(null) + prefs.edit().putBoolean("account_scoped_shade", true).commit() + } catch (_: Exception) { + org.monogram.core.common.AppLog.warn("notify", "legacy notification cleanup deferred") + } + } + @Synchronized fun show( context: Context, @@ -105,20 +137,23 @@ object NotificationPresenter { badge: BadgeSettings = BadgeSettings(), quiet: Boolean = false, pictureFile: File? = null, + accountUserId: Long? = null, ) { if (!decision.show) return + val local = NotificationLocalStore(context, accountUserId) + if (!local.notificationsEnabled || !notificationsEnabled(context)) return if (!mayPostNotifications( Build.VERSION.SDK_INT, - ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == - android.content.pm.PackageManager.PERMISSION_GRANTED, + ContextCompat.checkSelfPermission( + context, android.Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED, ) ) { return } val chatId = payload.chatId ?: 0L - val id = notificationIdFor(payload) + val id = notificationIdFor(payload, accountUserId) val active = activeBatch(context, id) - val local = NotificationLocalStore(context) var ledger = local.notificationLedger(chatId) if (!quiet && active == null && ledger.pending.isNotEmpty()) ledger = ledger.dismiss() val messageId = payload.messageId ?: 0 @@ -128,9 +163,12 @@ object NotificationPresenter { if (!quiet && chatId != 0L) ledger = ledger.append(messageId, payload.topicId) val preview = decision.preview && (!quiet || active?.preview != false) // A quiet repaint only refreshes a notification that is still in the shade. - if (quiet && (active == null || active.messages.none { it.messageId == payload.messageId } || - (messageId > 0 && messageId !in ledger.pending))) return - val title = if (preview) payload.title else context.getString(R.string.push_hidden_title) + if (quiet && (active == null || active.messages.none { it.messageId == payload.messageId } || (messageId > 0 && messageId !in ledger.pending))) return + val accountLabel = accountLabel(context, accountUserId) + val baseTitle = + if (preview) payload.title else context.getString(R.string.push_hidden_title) + val title = + accountQualifiedNotificationTitle(baseTitle, accountLabel, accountCount(context)) val body = shadeText(preview, payload.body, context.getString(R.string.push_hidden_body)) val channel = NotificationChannels.channelFor( context = context, @@ -141,7 +179,9 @@ object NotificationPresenter { custom = mode != null && !mode.isDefault, sound = decision.sound, popup = decision.popup, + store = local, ) + if (!channelEnabled(context, channel)) return val avatarBitmap = if (preview) avatarBitmap(avatarFile) else null val avatar = avatarBitmap?.let { IconCompat.createWithBitmap(it) } val now = System.currentTimeMillis() @@ -159,10 +199,10 @@ object NotificationPresenter { style.personKey ?: NotificationConversation.peerPersonKey(chatId), avatar, ) - val shortcutId = style.shortcutId + val shortcutId = style.shortcutId?.let { shortcutId(chatId, accountUserId) } val demoted = shortcutId != null && isConversationDemoted(context, shortcutId) val bubbleIcon = if (shouldAttachBubble( - enabled = NotificationLocalStore(context).bubblesEnabled, + enabled = NotificationLocalStore(context, accountUserId).bubblesEnabled, conversation = shortcutId != null, demoted = demoted, ) @@ -175,12 +215,19 @@ object NotificationPresenter { context = context, shortcutId = shortcutId, chatId = chatId, - label = style.shortcutLabel ?: title, + label = accountQualifiedNotificationTitle( + style.shortcutLabel ?: title, + accountLabel, + accountCount(context), + ), locusId = style.locusId ?: shortcutId, person = person, - icon = bubbleIcon ?: avatar ?: IconCompat.createWithResource(context, R.mipmap.ic_launcher), + icon = bubbleIcon ?: avatar ?: IconCompat.createWithResource( + context, R.mipmap.ic_launcher + ), messageId = payload.messageId ?: 0, bubble = bubbleIcon != null, + accountUserId = accountUserId, ) val incoming = NotificationMessage( payload.messageId ?: 0, body, System.currentTimeMillis(), @@ -200,10 +247,7 @@ object NotificationPresenter { val batch = if (quiet) { incoming.pictureUri?.let { uri -> NotificationBatch.attachPicture( - previous, - incoming.messageId, - uri, - incoming.pictureMimeType + previous, incoming.messageId, uri, incoming.pictureMimeType ) } ?: previous } else { @@ -214,7 +258,8 @@ object NotificationPresenter { it.copy(text = body, senderName = null, senderKey = null, pictureUri = null) } else batch if (shown.isEmpty()) return - val silent = !style.countsAsNewAlert || payload.silent || (!decision.sound && mode?.sound != false) + val silent = + !style.countsAsNewAlert || payload.silent || (!decision.sound && mode?.sound != false) if (style.countsAsNewAlert && !payload.silent && (decision.sound || mode?.sound == false)) { lastAlertAt[id] = now } @@ -250,6 +295,7 @@ object NotificationPresenter { NotificationCompat.CATEGORY_MESSAGE }, stayUntilOpened = call.staysUntilOpened, + accountUserId = accountUserId, ) builder.extras.putBoolean("org.monogram.push.preview", preview) val bubbleShortcut = shortcutId?.takeIf { @@ -278,7 +324,7 @@ object NotificationPresenter { } else { pendingPosts[id] = builder.build() to android.os.SystemClock.elapsedRealtime() if (chatId != 0L && !quiet) local.saveNotificationLedger(chatId, ledger) - refreshSummary(context, badge, id, builder.build()) + refreshSummary(context, badge, id, builder.build(), accountUserId) } } @@ -297,32 +343,44 @@ object NotificationPresenter { FileProvider.getUriForFile(context, "${context.packageName}.files", file) }.getOrNull() ?: return null val granted = runCatching { - context.grantUriPermission(SYSTEM_UI_PACKAGE, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.grantUriPermission( + SYSTEM_UI_PACKAGE, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) }.isSuccess return uri.takeIf { granted } } - fun removeShortcut(context: Context, chatId: Long) { + fun removeShortcut(context: Context, chatId: Long, accountUserId: Long? = null) { if (chatId == 0L) return - removeShareShortcuts(context, listOf(chatId)) + removeShareShortcuts(context, listOf(chatId), accountUserId) } - private fun rememberShareChat(context: Context, chatId: Long): Int { - val local = NotificationLocalStore(context) + private fun rememberShareChat( + context: Context, + chatId: Long, + accountUserId: Long? = null, + ): Int { + val local = NotificationLocalStore(context, accountUserId) val maxCount = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) val previous = local.shareChatIds val next = rankedShareChatIds(previous, chatId, maxCount) - removeShareShortcuts(context, evictedShareChatIds(previous, next)) + removeShareShortcuts(context, evictedShareChatIds(previous, next), accountUserId) if (next != previous) local.shareChatIds = next return next.indexOf(chatId).coerceAtLeast(0) } - private fun removeShareShortcuts(context: Context, chatIds: List) { - val ids = chatIds.filter { it != 0L }.map { "chat:$it" } + private fun removeShareShortcuts( + context: Context, + chatIds: List, + accountUserId: Long? = null, + ) { + val ids = chatIds.filter { it != 0L }.map { shortcutId(it, accountUserId) } if (ids.isEmpty()) return runCatching { ShortcutManagerCompat.removeDynamicShortcuts(context, ids) - if (Build.VERSION.SDK_INT >= 30) ShortcutManagerCompat.removeLongLivedShortcuts(context, ids) + if (Build.VERSION.SDK_INT >= 30) ShortcutManagerCompat.removeLongLivedShortcuts( + context, ids + ) } } @@ -341,50 +399,86 @@ object NotificationPresenter { * signed-out account leaves no chat names, avatars or reply actions behind in the shade. */ @Synchronized - fun clear(context: Context) { - pendingPosts.clear() - summaryPosted = false - NotificationLocalStore(context).clearNotificationLedgers() - runCatching { NotificationManagerCompat.from(context).cancelAll() } + fun clear(context: Context, accountUserId: Long? = null) { + val local = NotificationLocalStore(context, accountUserId) + val chatIds = local.notificationChatIds().toList() + if (accountUserId == null) { + pendingPosts.clear() + summariesPosted.clear() + } else { + summariesPosted.remove(accountUserId) + chatIds.forEach { chatId -> + pendingPosts.remove(notificationId(chatId, accountUserId)) + } + } + local.clearNotificationLedgers() + if (accountUserId == null) { + runCatching { NotificationManagerCompat.from(context).cancelAll() } + } else { + val manager = NotificationManagerCompat.from(context) + chatIds.forEach { chatId -> manager.cancel(notificationId(chatId, accountUserId)) } + manager.cancel(notificationId(SUMMARY_ID.toLong(), accountUserId)) + removeShareShortcuts(context, chatIds, accountUserId) + } runCatching { - if (Build.VERSION.SDK_INT >= 30) { - val ids = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id } - if (ids.isNotEmpty()) ShortcutManagerCompat.removeLongLivedShortcuts(context, ids) + if (accountUserId == null) lastAlertAt.clear() + } + } + + fun clearDisabled(context: Context, accountUserId: Long? = null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = NotificationManagerCompat.from(context) + manager.activeNotifications.forEach { active -> + val owner = + active.notification.extras.getLong(EXTRA_ACCOUNT_USER_ID, 0L).takeIf { it > 0L } + if (owner != accountUserId && !(owner == null && accountUserId == null)) return@forEach + val channel = manager.getNotificationChannel(active.notification.channelId) + if (channel?.importance == android.app.NotificationManager.IMPORTANCE_NONE) { + manager.cancel(active.id) } - ShortcutManagerCompat.removeAllDynamicShortcuts(context) - lastAlertAt.clear() } } @Synchronized - fun cancel(context: Context, chatId: Long, badge: BadgeSettings = BadgeSettings()) { - pendingPosts.remove(notificationId(chatId)) - val local = NotificationLocalStore(context) + fun cancel( + context: Context, + chatId: Long, + badge: BadgeSettings = BadgeSettings(), + accountUserId: Long? = null, + ) { + pendingPosts.remove(notificationId(chatId, accountUserId)) + val local = NotificationLocalStore(context, accountUserId) local.saveNotificationLedger(chatId, local.notificationLedger(chatId).dismiss()) - NotificationManagerCompat.from(context).cancel(notificationId(chatId)) - refreshSummary(context, badge, notificationId(chatId), null) + NotificationManagerCompat.from(context).cancel(notificationId(chatId, accountUserId)) + if (accountUserId == null) { + refreshSummary( + context, badge, notificationId(chatId, accountUserId), null, accountUserId + ) + } } - fun suppressVisibleBubble(context: Context, chatId: Long, bubblesEnabled: Boolean): Boolean { + fun suppressVisibleBubble( + context: Context, + chatId: Long, + bubblesEnabled: Boolean, + accountUserId: Long? = null, + ): Boolean { if (!bubblesEnabled || Build.VERSION.SDK_INT < 30) return false - val id = notificationId(chatId) - val active = NotificationManagerCompat.from(context).activeNotifications - .firstOrNull { it.id == id } ?: return false + val id = notificationId(chatId, accountUserId) + val active = + NotificationManagerCompat.from(context).activeNotifications.firstOrNull { it.id == id } + ?: return false val post = active.notification if (post.flags and Notification.FLAG_BUBBLE == 0) return false val metadata = post.bubbleMetadata ?: return false val shortcutId = metadata.shortcutId ?: return false if (metadata.isNotificationSuppressed && !metadata.autoExpandBubble) return true return runCatching { - val builder = Notification.Builder.recoverBuilder(context, post) - .setOnlyAlertOnce(true) - .setNumber(0) - .setBubbleMetadata( + val builder = Notification.Builder.recoverBuilder(context, post).setOnlyAlertOnce(true) + .setNumber(0).setBubbleMetadata( Notification.BubbleMetadata.Builder(shortcutId) - .setDesiredHeight(BUBBLE_HEIGHT_DP) - .setSuppressNotification(true) - .setAutoExpandBubble(false) - .build(), + .setDesiredHeight(BUBBLE_HEIGHT_DP).setSuppressNotification(true) + .setAutoExpandBubble(false).build(), ) NotificationManagerCompat.from(context).notify(id, builder.build()) }.isSuccess @@ -395,11 +489,14 @@ object NotificationPresenter { * notification is no longer in the shade. */ @Synchronized - fun realert(context: Context, chatId: Long): Boolean { + fun realert(context: Context, chatId: Long, accountUserId: Long? = null): Boolean { + val local = NotificationLocalStore(context, accountUserId) + if (!local.notificationsEnabled || !notificationsEnabled(context)) return false val manager = NotificationManagerCompat.from(context) - val id = notificationId(chatId) + val id = notificationId(chatId, accountUserId) val post = activeNotification(context, id) ?: return false - val title = post.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString().orEmpty() + val title = + post.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString().orEmpty() val messages = batchMessages(post).ifEmpty { return false } val icon = post.getLargeIcon() val iconCompat = icon?.let { candidate -> @@ -418,15 +515,16 @@ object NotificationPresenter { messages = messages, count = NotificationBadgePlan.number( post.extras.getInt(EXTRA_UNREAD_COUNT, messages.size), - NotificationLocalStore(context).badgeSettings(), + local.badgeSettings(), ), unreadCount = post.extras.getInt(EXTRA_UNREAD_COUNT, messages.size), - shortcutId = if (chatId != 0L) "chat:$chatId" else null, + shortcutId = if (chatId != 0L) shortcutId(chatId, accountUserId) else null, largeIcon = icon, pictureUri = null, pictureBitmap = null, - conversationTitle = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(post) - ?.conversationTitle?.toString(), + conversationTitle = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification( + post + )?.conversationTitle?.toString(), messageSenderName = null, allowReply = chatId != 0L, silent = false, @@ -434,29 +532,92 @@ object NotificationPresenter { maxId = 0, category = NotificationCompat.CATEGORY_MESSAGE, stayUntilOpened = false, + accountUserId = accountUserId, ) builder.extras.putBoolean( - "org.monogram.push.preview", - post.extras.getBoolean("org.monogram.push.preview", true) + "org.monogram.push.preview", post.extras.getBoolean("org.monogram.push.preview", true) ) - if (!(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || - ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == - android.content.pm.PackageManager.PERMISSION_GRANTED)) return false + if (!(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission( + context, android.Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED) + ) return false val replacement = builder.build() + if (!channelEnabled(context, replacement.channelId)) return false return runCatching { manager.notify(id, replacement) }.isSuccess.also { success -> if (success) pendingPosts[id] = replacement to android.os.SystemClock.elapsedRealtime() } } - fun notificationId(chatId: Long): Int = (chatId xor (chatId ushr 32)).toInt() and Int.MAX_VALUE + private fun summaryId(accountUserId: Long?): Int = + accountUserId?.let { notificationId(SUMMARY_ID.toLong(), it) } ?: SUMMARY_ID + + private fun accountLabel(context: Context, accountUserId: Long?): String? { + if (accountUserId == null) return null + val app = context.applicationContext as? org.monogram.MonogramApp ?: return null + return runCatching { + val accounts = app.accountRegistry.all() + if (accounts.size < 2 || accounts.none { it.userId == accountUserId }) null + else "${context.getString(org.monogram.feature.settings.R.string.settings_account)} $accountUserId" + }.getOrNull() + } + + private fun accountCount(context: Context): Int = runCatching { + (context.applicationContext as? org.monogram.MonogramApp)?.accountRegistry?.all()?.size ?: 0 + }.getOrDefault(0) + + internal fun accountQualifiedNotificationTitle( + title: String, + accountLabel: String?, + accountCount: Int, + ): String = if (accountCount > 1 && accountLabel != null) "$title · $accountLabel" else title + + internal fun shortcutId(chatId: Long, accountUserId: Long?): String = + accountUserId?.let { "account:$it:chat:$chatId" } ?: "chat:$chatId" + + private fun notificationsEnabled(context: Context): Boolean = + NotificationManagerCompat.from(context).areNotificationsEnabled() + + private fun channelEnabled(context: Context, channelId: String): Boolean { + if (!notificationsEnabled(context)) return false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true + val manager = + context.getSystemService(android.app.NotificationManager::class.java) ?: return false + return manager.getNotificationChannel(channelId)?.importance != android.app.NotificationManager.IMPORTANCE_NONE + } + + + fun notificationId(chatId: Long, accountUserId: Long? = null): Int = + if (accountUserId == null) { + (chatId xor (chatId ushr 32)).toInt() and Int.MAX_VALUE + } else { + var hash = accountUserId xor (accountUserId ushr 32) + hash = hash * 31 + chatId + (hash xor (hash ushr 32)).toInt() and Int.MAX_VALUE + } + + fun notificationActionsEnabled( + context: Context, + chatId: Long, + accountUserId: Long? = null, + ): Boolean { + if (!notificationsEnabled(context)) return false + val notification = + activeNotification(context, notificationId(chatId, accountUserId)) ?: return false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true + return NotificationManagerCompat.from(context) + .getNotificationChannel(notification.channelId)?.importance != android.app.NotificationManager.IMPORTANCE_NONE + } /** * Payloads without a chat (contact joined, pinned, authorization) still need a stable id, so * different kinds do not overwrite each other in the shade. */ - fun notificationIdFor(payload: PushPayload): Int = - payload.chatId?.let { notificationId(it) } - ?: (("loc:${payload.locKey}").hashCode() and Int.MAX_VALUE) + fun notificationIdFor(payload: PushPayload, accountUserId: Long? = null): Int = + payload.chatId?.let { notificationId(it, accountUserId) } ?: if (accountUserId == null) { + "loc:${payload.locKey}".hashCode() and Int.MAX_VALUE + } else { + notificationId("loc:${payload.locKey}".hashCode().toLong(), accountUserId) + } private fun builderFor( context: Context, @@ -480,6 +641,7 @@ object NotificationPresenter { category: String, stayUntilOpened: Boolean, unreadCount: Int = count, + accountUserId: Long? = null, ): NotificationCompat.Builder { val last = messages.last() val messageId = last.messageId.takeIf { it > 0 } ?: 0 @@ -494,15 +656,20 @@ object NotificationPresenter { message.senderKey ?: NotificationConversation.senderPersonKey(senderName), null, ) + messageSenderName != null -> senderPerson( messageSenderName, person.key ?: NotificationConversation.peerPersonKey(chatId), null, ) + else -> person } - val entry = NotificationCompat.MessagingStyle.Message(message.text, message.timestamp, author) - if (message.messageId != 0) entry.extras.putInt(EXTRA_BATCH_MESSAGE_ID, message.messageId) + val entry = + NotificationCompat.MessagingStyle.Message(message.text, message.timestamp, author) + if (message.messageId != 0) entry.extras.putInt( + EXTRA_BATCH_MESSAGE_ID, message.messageId + ) if (message.edited) entry.extras.putBoolean(EXTRA_BATCH_EDITED, true) message.topicId?.let { entry.extras.putInt(EXTRA_BATCH_TOPIC_ID, it) } // Keep attachments with their message across thumbnail and avatar refreshes. @@ -515,44 +682,43 @@ object NotificationPresenter { } val builder = NotificationCompat.Builder(context, channel) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) - .setSmallIcon(R.drawable.ic_notification) - .setColor(NOTIFICATION_COLOR) - .setContentTitle(title) - .setWhen(last.timestamp) - .setContentText(last.text) - .setStyle(pictureStyle ?: style) - .setNumber(count) + .setSmallIcon(R.drawable.ic_notification).setColor(NOTIFICATION_COLOR) + .setContentTitle(title).setWhen(last.timestamp).setContentText(last.text) + .setStyle(pictureStyle ?: style).setNumber(count) // Telegram shows the unread count for a collapsed chat (NotificationsController:5657). .apply { if (unreadCount > 1) { setSubText( context.resources.getQuantityString( - R.plurals.push_new_messages, - unreadCount, - unreadCount + R.plurals.push_new_messages, unreadCount, unreadCount ) ) } - } - .setCategory(category) - .setGroup(GROUP_KEY) + }.setCategory(category).setGroup(groupKey(accountUserId)) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - .setAutoCancel(!stayUntilOpened) - .setOnlyAlertOnce(onlyAlertOnce) - .setContentIntent(pendingActivity(context, id, openChatIntent(context, chatId, messageId))) - .setPriority( + .setAutoCancel(!stayUntilOpened).setOnlyAlertOnce(onlyAlertOnce).apply { + accountUserId?.let { extras.putLong(EXTRA_ACCOUNT_USER_ID, it) } + }.setContentIntent( + pendingActivity( + context, id, openChatIntent(context, chatId, messageId, accountUserId) + ) + ).setPriority( if (silent) NotificationCompat.PRIORITY_DEFAULT else NotificationCompat.PRIORITY_HIGH, ) if (shortcutId != null) builder.setShortcutId(shortcutId) builder.addPerson(person) if (largeIcon != null) builder.setLargeIcon(largeIcon) - builder.setDeleteIntent(dismissIntent(context, id, chatId, messages.maxOf { it.messageId })) + builder.setDeleteIntent( + dismissIntent( + context, id, chatId, messages.maxOf { it.messageId }, accountUserId + ) + ) if (allowReply && chatId != 0L) { openLinkAction(context, id, last.text)?.let { builder.addAction(it) } - builder.addAction(replyAction(context, id, chatId, messageId, maxId)) - .addAction(markReadAction(context, id, chatId, messageId, maxId)) - .addAction(muteAction(context, id, chatId)) - .addAction(notificationSettingsAction(context, id)) + builder.addAction(replyAction(context, id, chatId, messageId, maxId, accountUserId)) + .addAction(markReadAction(context, id, chatId, messageId, maxId, accountUserId)) + .addAction(muteAction(context, id, chatId, accountUserId)) + .addAction(notificationSettingsAction(context, id, accountUserId)) } if (silent) builder.setSilent(true) builder.extras.putBoolean(EXTRA_CHILD, true) @@ -562,9 +728,7 @@ object NotificationPresenter { } private class ActiveBatch( - val messages: List, - val count: Int, - val preview: Boolean + val messages: List, val count: Int, val preview: Boolean ) private fun activeNotification(context: Context, id: Int): Notification? { @@ -593,7 +757,8 @@ object NotificationPresenter { } private fun batchMessages(notification: Notification): List { - val style = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(notification) + val style = + NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(notification) val userKey = style?.user?.key return style?.messages.orEmpty().mapNotNull { message -> val text = message.text?.toString() ?: return@mapNotNull null @@ -613,31 +778,40 @@ object NotificationPresenter { } private fun selfPerson(context: Context): Person = - Person.Builder() - .setName(context.getString(R.string.push_you)) - .setKey(NotificationConversation.SELF_PERSON_KEY) - .build() + Person.Builder().setName(context.getString(R.string.push_you)) + .setKey(NotificationConversation.SELF_PERSON_KEY).build() private fun senderPerson(name: String, key: String, avatar: IconCompat?): Person = - Person.Builder() - .setName(name) - .setKey(key) - .apply { if (avatar != null) setIcon(avatar) } + Person.Builder().setName(name).setKey(key).apply { if (avatar != null) setIcon(avatar) } .build() /** Re-posts the collapsed batch summary, or clears it when a chat notification left the shade. */ @Synchronized - fun refreshSummary(context: Context, badge: BadgeSettings = BadgeSettings()) = - refreshSummary(context, badge, null, null) + fun refreshSummary( + context: Context, + badge: BadgeSettings = BadgeSettings(), + accountUserId: Long? = null, + ) = refreshSummary(context, badge, null, null, accountUserId) private fun refreshSummary( context: Context, badge: BadgeSettings, changedId: Int?, - replacement: Notification? + replacement: Notification?, + accountUserId: Long? = null, ) { + if (!NotificationLocalStore( + context, accountUserId + ).notificationsEnabled || !notificationsEnabled(context) + ) return val manager = NotificationManagerCompat.from(context) - val active = manager.activeNotifications + val summaryId = summaryId(accountUserId) + val groupKey = groupKey(accountUserId) + val active = manager.activeNotifications.filter { active -> + val owner = + active.notification.extras.getLong(EXTRA_ACCOUNT_USER_ID, 0L).takeIf { it > 0L } + owner == accountUserId || (owner == null && accountUserId == null) + } val posts = active.associate { it.id to (it.notification to it.postTime) }.toMutableMap() pendingPosts.keys.toList().forEach { id -> val current = activeNotification(context, id) @@ -645,29 +819,27 @@ object NotificationPresenter { else posts[id] = current to current.`when` } val children = (posts.filterKeys { it != changedId } - .map { (id, value) -> Triple(id, value.first, value.second) } + - listOfNotNull(replacement?.let { - Triple( - requireNotNull(changedId), - it, - System.currentTimeMillis() - ) - })) - .filter { (_, notification, _) -> - (notification.group == GROUP_KEY || notification.extras.getBoolean(EXTRA_CHILD)) && - notification.flags and Notification.FLAG_GROUP_SUMMARY == 0 - } - .sortedByDescending { it.third } + .map { (id, value) -> Triple(id, value.first, value.second) } + listOfNotNull( + replacement?.let { + Triple( + requireNotNull(changedId), it, System.currentTimeMillis() + ) + })).filter { (_, notification, _) -> + notification.extras.getLong(EXTRA_ACCOUNT_USER_ID, 0L) + .takeIf { it > 0L } == accountUserId && (notification.group == GROUP_KEY || notification.group == groupKey || notification.extras.getBoolean( + EXTRA_CHILD + )) && notification.flags and Notification.FLAG_GROUP_SUMMARY == 0 + }.sortedByDescending { it.third } if (children.size < 2) { - val hadSummary = summaryPosted || active.any { it.id == SUMMARY_ID } - summaryPosted = false - manager.cancel(SUMMARY_ID) + val hadSummary = + summariesPosted.remove(accountUserId) || active.any { it.id == summaryId } + manager.cancel(summaryId) if (hadSummary) children.singleOrNull()?.let { child -> - val detached = NotificationCompat.Builder(context, child.second) - .setGroup(null).setSilent(true).setOnlyAlertOnce(true).build() + val detached = + NotificationCompat.Builder(context, child.second).setGroup(null).setSilent(true) + .setOnlyAlertOnce(true).build() detached.extras.putLong( - EXTRA_REVISION, - android.os.SystemClock.elapsedRealtimeNanos() + EXTRA_REVISION, android.os.SystemClock.elapsedRealtimeNanos() ) if (runCatching { manager.notify(child.first, detached) }.isSuccess) { pendingPosts[child.first] = detached to android.os.SystemClock.elapsedRealtime() @@ -675,24 +847,24 @@ object NotificationPresenter { } return } - children.filter { it.second.group != GROUP_KEY }.forEach { child -> - val grouped = NotificationCompat.Builder(context, child.second) - .setGroup(GROUP_KEY).setSilent(true).setOnlyAlertOnce(true).build() + children.filter { it.second.group != groupKey }.forEach { child -> + val grouped = + NotificationCompat.Builder(context, child.second).setGroup(groupKey).setSilent(true) + .setOnlyAlertOnce(true).build() grouped.extras.putLong(EXTRA_REVISION, android.os.SystemClock.elapsedRealtimeNanos()) if (runCatching { manager.notify(child.first, grouped) }.isSuccess) { pendingPosts[child.first] = grouped to android.os.SystemClock.elapsedRealtime() } } val channel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - active.firstOrNull { it.id == SUMMARY_ID }?.notification?.channelId + active.firstOrNull { it.id == summaryId }?.notification?.channelId ?: children.first().second.channelId } else { NotificationChannels.OTHER } val total = children.sumOf { it.second.extras.getInt( - EXTRA_UNREAD_COUNT, - it.second.number.coerceAtLeast(1) + EXTRA_UNREAD_COUNT, it.second.number.coerceAtLeast(1) ) } val lines = NotificationCompat.InboxStyle() @@ -701,41 +873,48 @@ object NotificationPresenter { child.second.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() val text = child.second.extras.getCharSequence(NotificationCompat.EXTRA_TEXT)?.toString() - val line = listOfNotNull(title?.takeIf { it.isNotBlank() }, text?.takeIf { it.isNotBlank() }) - .joinToString(": ") + val line = listOfNotNull( + title?.takeIf { it.isNotBlank() }, + text?.takeIf { it.isNotBlank() }).joinToString(": ") if (line.isNotBlank()) lines.addLine(line) } if (children.size > MAX_SUMMARY_LINES) { - lines.setSummaryText(context.getString(R.string.push_more_chats, children.size - MAX_SUMMARY_LINES)) + lines.setSummaryText( + context.getString( + R.string.push_more_chats, children.size - MAX_SUMMARY_LINES + ) + ) } val summary = NotificationCompat.Builder(context, channel) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) - .setSmallIcon(R.drawable.ic_notification) - .setColor(NOTIFICATION_COLOR) - .setContentTitle(context.getString(R.string.app_name)) - .setContentText(context.resources.getQuantityString(R.plurals.push_new_messages, total, total)) - .setStyle(lines) - .setNumber(NotificationBadgePlan.number(total, badge)) - .setGroup(GROUP_KEY) - .setGroupSummary(true) + .setSmallIcon(R.drawable.ic_notification).setColor(NOTIFICATION_COLOR) + .setContentTitle(context.getString(R.string.app_name)).setContentText( + context.resources.getQuantityString( + R.plurals.push_new_messages, total, total + ) + ).setStyle(lines).setNumber(NotificationBadgePlan.number(total, badge)) + .setGroup(groupKey).setGroupSummary(true) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setAutoCancel(true) - .setOnlyAlertOnce(true) - .setSilent(true) - .setContentIntent(pendingActivity(context, SUMMARY_ID, openAppIntent(context))) - .build() - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || - ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == - android.content.pm.PackageManager.PERMISSION_GRANTED) { - if (runCatching { manager.notify(SUMMARY_ID, summary) }.isSuccess) summaryPosted = true + .setCategory(NotificationCompat.CATEGORY_MESSAGE).setAutoCancel(true).apply { + accountUserId?.let { extras.putLong(EXTRA_ACCOUNT_USER_ID, it) } + }.setOnlyAlertOnce(true).setSilent(true) + .setContentIntent(pendingActivity(context, summaryId, openAppIntent(context))).build() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission( + context, android.Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + if (runCatching { manager.notify(summaryId, summary) }.isSuccess) summariesPosted.add( + accountUserId + ) } } - private fun openLinkAction(context: Context, id: Int, body: String): NotificationCompat.Action? { + private fun openLinkAction( + context: Context, id: Int, body: String + ): NotificationCompat.Action? { val url = notificationHttpUrl(body) ?: return null - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val pending = PendingIntent.getActivity( context, id xor 3, @@ -755,15 +934,13 @@ object NotificationPresenter { chatId: Long, messageId: Int, maxId: Int, + accountUserId: Long? = null, ): NotificationCompat.Action { val remote = RemoteInput.Builder(KEY_TEXT_REPLY) - .setLabel(context.getString(R.string.push_reply_hint)) - .build() - val intent = Intent(context, NotificationActionReceiver::class.java) - .setAction(ACTION_REPLY) - .putExtra(EXTRA_CHAT_ID, chatId) - .putExtra(EXTRA_MESSAGE_ID, messageId) - .putExtra(EXTRA_MAX_ID, maxId) + .setLabel(context.getString(R.string.push_reply_hint)).build() + val intent = Intent(context, NotificationActionReceiver::class.java).setAction(ACTION_REPLY) + .putExtra(EXTRA_CHAT_ID, chatId).putExtra(EXTRA_MESSAGE_ID, messageId) + .putExtra(EXTRA_MAX_ID, maxId).putExtra(EXTRA_ACCOUNT_USER_ID, accountUserId ?: 0L) val pending = PendingIntent.getBroadcast( context, id xor 1, @@ -774,12 +951,9 @@ object NotificationPresenter { R.drawable.ic_push_reply, context.getString(R.string.push_reply), pending, - ) - .addRemoteInput(remote) - .setAllowGeneratedReplies(true) + ).addRemoteInput(remote).setAllowGeneratedReplies(true) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) - .setShowsUserInterface(false) - .build() + .setShowsUserInterface(false).build() } private fun markReadAction( @@ -788,12 +962,12 @@ object NotificationPresenter { chatId: Long, messageId: Int, maxId: Int, + accountUserId: Long? = null, ): NotificationCompat.Action { - val intent = Intent(context, NotificationActionReceiver::class.java) - .setAction(ACTION_MARK_READ) - .putExtra(EXTRA_CHAT_ID, chatId) - .putExtra(EXTRA_MESSAGE_ID, messageId) - .putExtra(EXTRA_MAX_ID, maxId) + val intent = + Intent(context, NotificationActionReceiver::class.java).setAction(ACTION_MARK_READ) + .putExtra(EXTRA_CHAT_ID, chatId).putExtra(EXTRA_MESSAGE_ID, messageId) + .putExtra(EXTRA_MAX_ID, maxId).putExtra(EXTRA_ACCOUNT_USER_ID, accountUserId ?: 0L) val pending = PendingIntent.getBroadcast( context, id xor 2, @@ -804,16 +978,18 @@ object NotificationPresenter { R.drawable.ic_push_read, context.getString(R.string.push_mark_read), pending, - ) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) - .setShowsUserInterface(false) - .build() + ).setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .setShowsUserInterface(false).build() } - private fun muteAction(context: Context, id: Int, chatId: Long): NotificationCompat.Action { - val intent = Intent(context, NotificationActionReceiver::class.java) - .setAction(ACTION_MUTE) - .putExtra(EXTRA_CHAT_ID, chatId) + private fun muteAction( + context: Context, + id: Int, + chatId: Long, + accountUserId: Long? = null, + ): NotificationCompat.Action { + val intent = Intent(context, NotificationActionReceiver::class.java).setAction(ACTION_MUTE) + .putExtra(EXTRA_CHAT_ID, chatId).putExtra(EXTRA_ACCOUNT_USER_ID, accountUserId ?: 0L) val pending = PendingIntent.getBroadcast( context, id xor 8, @@ -824,14 +1000,20 @@ object NotificationPresenter { R.drawable.ic_push_read, context.getString(R.string.push_mute_hour), pending, - ) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MUTE) - .setShowsUserInterface(false) - .build() + ).setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MUTE) + .setShowsUserInterface(false).build() } - private fun notificationSettingsAction(context: Context, id: Int): NotificationCompat.Action { - val pending = pendingActivity(context, id xor 16, openNotificationsIntent(context)) + private fun notificationSettingsAction( + context: Context, + id: Int, + accountUserId: Long?, + ): NotificationCompat.Action { + val pending = pendingActivity( + context, + id xor 16, + openNotificationsIntent(context, accountUserId), + ) return NotificationCompat.Action.Builder( R.drawable.ic_notification, context.getString(R.string.push_notification_settings), @@ -840,12 +1022,16 @@ object NotificationPresenter { } @Synchronized - fun showNotSent(context: Context, chatId: Long) { - val id = notificationId(chatId) + fun showNotSent(context: Context, chatId: Long, accountUserId: Long? = null) { + val id = notificationId(chatId, accountUserId) val post = activeNotification(context, id) ?: return - val messages = batchMessages(post) + - NotificationMessage(0, context.getString(R.string.push_not_sent), System.currentTimeMillis()) - repost(context, chatId, post, messages, silent = true, onlyAlertOnce = true) + val messages = batchMessages(post) + NotificationMessage( + 0, context.getString(R.string.push_not_sent), System.currentTimeMillis() + ) + repost( + context, chatId, post, messages, silent = true, onlyAlertOnce = true, + accountUserId = accountUserId, + ) } @Synchronized @@ -855,30 +1041,39 @@ object NotificationPresenter { messageId: Int, text: String, badge: BadgeSettings, - hideMedia: Boolean = false + hideMedia: Boolean = false, + accountUserId: Long? = null, ) { - val post = activeNotification(context, notificationId(chatId)) ?: return + val post = activeNotification(context, notificationId(chatId, accountUserId)) ?: return if (!post.extras.getBoolean("org.monogram.push.preview", true)) return val messages = batchMessages(post) if (messages.none { it.messageId == messageId }) return - repost(context, chatId, post, messages.map { - if (it.messageId == messageId) it.copy( - text = text, - edited = true, - pictureUri = if (hideMedia) null else it.pictureUri - ) else it - }, silent = true, onlyAlertOnce = true, badge = badge) + repost( + context, chatId, post, + messages.map { + if (it.messageId == messageId) it.copy( + text = text, edited = true, pictureUri = if (hideMedia) null else it.pictureUri + ) else it + }, + silent = true, onlyAlertOnce = true, badge = badge, + accountUserId = accountUserId, + ) } @Synchronized - fun dropUnscopedMessages(context: Context, ids: Set, badge: BadgeSettings) { - val local = NotificationLocalStore(context) + fun dropUnscopedMessages( + context: Context, + ids: Set, + badge: BadgeSettings, + accountUserId: Long? = null, + ) { + val local = NotificationLocalStore(context, accountUserId) val activeChats = NotificationManagerCompat.from(context).activeNotifications.mapNotNull { it.notification.shortcutId?.removePrefix("chat:")?.toLongOrNull() } (local.notificationChatIds() + activeChats).filter { it != 0L && it > -org.monogram.core.models.CHANNEL_PEER_OFFSET - }.forEach { dropMessages(context, it, ids, null, badge) } + }.forEach { dropMessages(context, it, ids, null, badge, accountUserId = accountUserId) } } @Synchronized @@ -889,9 +1084,10 @@ object NotificationPresenter { upTo: Int?, badge: BadgeSettings, topicId: Int? = null, + accountUserId: Long? = null, ) { - val id = notificationId(chatId) - val local = NotificationLocalStore(context) + val id = notificationId(chatId, accountUserId) + val local = NotificationLocalStore(context, accountUserId) val ledger = local.notificationLedger(chatId).retain(dropIds, upTo, topicId) local.saveNotificationLedger(chatId, ledger) val post = activeNotification(context, id) ?: return @@ -913,8 +1109,10 @@ object NotificationPresenter { } if (remaining.isEmpty()) { if (upTo == null || !suppressVisibleBubble( - context, chatId, NotificationLocalStore(context).bubblesEnabled, - )) cancel(context, chatId, badge) + context, chatId, NotificationLocalStore(context, accountUserId).bubblesEnabled, + accountUserId, + ) + ) cancel(context, chatId, badge, accountUserId) } else { repost( context, @@ -923,7 +1121,8 @@ object NotificationPresenter { remaining, silent = true, onlyAlertOnce = true, - badge = badge + badge = badge, + accountUserId = accountUserId, ) } } @@ -935,18 +1134,21 @@ object NotificationPresenter { messages: List, silent: Boolean, onlyAlertOnce: Boolean, - badge: BadgeSettings = NotificationLocalStore(context).badgeSettings(), + accountUserId: Long? = null, + badge: BadgeSettings = NotificationLocalStore(context, accountUserId).badgeSettings(), ) { - if (messages.isEmpty()) return - val id = notificationId(chatId) - val title = post.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString().orEmpty() + val local = NotificationLocalStore(context, accountUserId) + if (messages.isEmpty() || !local.notificationsEnabled || !notificationsEnabled(context)) return + val id = notificationId(chatId, accountUserId) + val title = + post.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString().orEmpty() val icon = post.getLargeIcon() val iconCompat = icon?.let { candidate -> runCatching { candidate.loadDrawable(context)?.toBitmap() }.getOrNull() ?.let { IconCompat.createWithBitmap(it) } } val person = senderPerson(title, NotificationConversation.peerPersonKey(chatId), iconCompat) - val pendingCount = NotificationLocalStore(context).notificationLedger(chatId).pending.size + val pendingCount = local.notificationLedger(chatId).pending.size val unreadCount = maxOf(messages.size, pendingCount) val builder = builderFor( context = context, @@ -958,12 +1160,13 @@ object NotificationPresenter { messages = messages, count = NotificationBadgePlan.number(unreadCount, badge), unreadCount = unreadCount, - shortcutId = if (chatId != 0L) "chat:$chatId" else null, + shortcutId = if (chatId != 0L) shortcutId(chatId, accountUserId) else null, largeIcon = icon, pictureUri = null, pictureBitmap = null, - conversationTitle = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(post) - ?.conversationTitle?.toString(), + conversationTitle = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification( + post + )?.conversationTitle?.toString(), messageSenderName = null, allowReply = chatId != 0L, silent = silent, @@ -971,22 +1174,33 @@ object NotificationPresenter { maxId = 0, category = NotificationCompat.CATEGORY_MESSAGE, stayUntilOpened = false, + accountUserId = accountUserId, ) + if (!mayPostNotifications( Build.VERSION.SDK_INT, - ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == - android.content.pm.PackageManager.PERMISSION_GRANTED, + ContextCompat.checkSelfPermission( + context, android.Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED, ) ) { return } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && NotificationManagerCompat.from(context) + .getNotificationChannel( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) post.channelId else NotificationChannels.OTHER + )?.importance == android.app.NotificationManager.IMPORTANCE_NONE + ) return builder.extras.putBoolean( - "org.monogram.push.preview", - post.extras.getBoolean("org.monogram.push.preview", true) + "org.monogram.push.preview", post.extras.getBoolean("org.monogram.push.preview", true) ) if (Build.VERSION.SDK_INT >= 30) { post.bubbleMetadata?.shortcutId?.let { - builder.setBubbleMetadata(shortcutBubble(it, suppressNotification = false, expand = false)) + builder.setBubbleMetadata( + shortcutBubble( + it, suppressNotification = false, expand = false + ) + ) } } val replacement = builder.build() @@ -994,7 +1208,7 @@ object NotificationPresenter { NotificationManagerCompat.from(context).notify(id, replacement) }.isSuccess) { pendingPosts[id] = replacement to android.os.SystemClock.elapsedRealtime() - refreshSummary(context, badge, id, replacement) + refreshSummary(context, badge, id, replacement, accountUserId) } } @@ -1003,11 +1217,8 @@ object NotificationPresenter { suppressNotification: Boolean, expand: Boolean, ): NotificationCompat.BubbleMetadata = - NotificationCompat.BubbleMetadata.Builder(shortcutId) - .setDesiredHeight(BUBBLE_HEIGHT_DP) - .setAutoExpandBubble(expand) - .setSuppressNotification(suppressNotification) - .build() + NotificationCompat.BubbleMetadata.Builder(shortcutId).setDesiredHeight(BUBBLE_HEIGHT_DP) + .setAutoExpandBubble(expand).setSuppressNotification(suppressNotification).build() private fun publishConversationShortcut( context: Context, @@ -1019,39 +1230,35 @@ object NotificationPresenter { icon: IconCompat, messageId: Int, bubble: Boolean, + accountUserId: Long? = null, ): Boolean { - val rank = rememberShareChat(context, chatId) - val shortcutPerson = Person.Builder() - .setName(person.name) - .setKey(person.key) - .setUri(person.uri) - .setIcon(person.icon) - .setBot(person.isBot) - .setImportant(true) - .build() + val rank = rememberShareChat(context, chatId, accountUserId) + val shortcutPerson = + Person.Builder().setName(person.name).setKey(person.key).setUri(person.uri) + .setIcon(person.icon).setBot(person.isBot).setImportant(true).build() val shortcut = ShortcutInfoCompat.Builder(context, shortcutId) .setShortLabel(label.take(30).ifBlank { context.getString(R.string.app_name) }) - .setLongLabel(label) - .setLocusId(LocusIdCompat(locusId)) - .setLongLived(true) - .setIsConversation() - .setRank(rank) - .setPerson(shortcutPerson) - .setIcon(icon) - .setIntent( - if (bubble) bubbleChatIntent(context, chatId, messageId) - else openChatIntent(context, chatId, messageId), + .setLongLabel(label).setLocusId(LocusIdCompat(locusId)).setLongLived(true) + .setIsConversation().setRank(rank).setPerson(shortcutPerson).setIcon(icon).setIntent( + if (bubble) bubbleChatIntent(context, chatId, messageId, accountUserId) + else openChatIntent(context, chatId, messageId, accountUserId), + ).build() + return runCatching { + ShortcutManagerCompat.pushDynamicShortcut( + context, shortcut ) - .build() - return runCatching { ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) }.isSuccess + }.isSuccess } - private fun bubbleChatIntent(context: Context, chatId: Long, messageId: Int): Intent = - Intent(context, BubbleActivity::class.java) - .setAction(ACTION_OPEN_CHAT) - .putExtra(EXTRA_CHAT_ID, chatId) - .putExtra(EXTRA_MESSAGE_ID, messageId) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + internal fun bubbleChatIntent( + context: Context, + chatId: Long, + messageId: Int, + accountUserId: Long? = null, + ): Intent = Intent(context, BubbleActivity::class.java).setAction(ACTION_OPEN_CHAT) + .putExtra(EXTRA_CHAT_ID, chatId).putExtra(EXTRA_MESSAGE_ID, messageId) + .putExtra(EXTRA_ACCOUNT_USER_ID, accountUserId ?: 0L) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) /** 108dp adaptive canvas; the picture stays inside the 72dp safe zone so the bubble is not a white ring. */ private fun bubbleIcon(context: Context, chatId: Long, avatar: Bitmap?): IconCompat? { @@ -1071,7 +1278,9 @@ object NotificationPresenter { } canvas.save() canvas.clipPath(Path().apply { addOval(dest, Path.Direction.CW) }) - canvas.drawBitmap(source, null, dest, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)) + canvas.drawBitmap( + source, null, dest, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + ) canvas.restore() if (Build.VERSION.SDK_INT < 30) return IconCompat.createWithAdaptiveBitmap(bitmap) val file = File(context.cacheDir, "bubbles/$chatId.png") @@ -1084,9 +1293,10 @@ object NotificationPresenter { return IconCompat.createWithAdaptiveBitmapContentUri(uri) } - private fun openNotificationsIntent(context: Context): Intent = - Intent(context, MainActivity::class.java) - .setAction(ACTION_OPEN_NOTIFICATIONS) + private fun openNotificationsIntent(context: Context, accountUserId: Long?): Intent = + Intent(context, MainActivity::class.java).putExtra( + EXTRA_ACCOUNT_USER_ID, accountUserId ?: 0L + ).setAction(ACTION_OPEN_NOTIFICATIONS) .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) private fun pendingActivity(context: Context, requestCode: Int, intent: Intent): PendingIntent = @@ -1097,14 +1307,20 @@ object NotificationPresenter { PendingIntent.FLAG_UPDATE_CURRENT or immutableFlags(), ) - private fun dismissIntent(context: Context, id: Int, chatId: Long, maxId: Int): PendingIntent = - PendingIntent.getBroadcast( - context, - id xor 4, - Intent(context, NotificationActionReceiver::class.java).setAction(ACTION_DISMISS) - .putExtra(EXTRA_CHAT_ID, chatId).putExtra(EXTRA_MESSAGE_ID, maxId), - PendingIntent.FLAG_UPDATE_CURRENT or immutableFlags(), - ) + private fun dismissIntent( + context: Context, + id: Int, + chatId: Long, + maxId: Int, + accountUserId: Long? = null, + ): PendingIntent = PendingIntent.getBroadcast( + context, + id xor 4, + Intent(context, NotificationActionReceiver::class.java).setAction(ACTION_DISMISS) + .putExtra(EXTRA_CHAT_ID, chatId).putExtra(EXTRA_MESSAGE_ID, maxId) + .putExtra(EXTRA_ACCOUNT_USER_ID, accountUserId ?: 0L), + PendingIntent.FLAG_UPDATE_CURRENT or immutableFlags(), + ) private fun immutableFlags(): Int = if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0 @@ -1112,16 +1328,19 @@ object NotificationPresenter { private fun mutableFlags(): Int = if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0 - private fun openChatIntent(context: Context, chatId: Long, messageId: Int): Intent = - Intent(context, MainActivity::class.java) - .setAction(ACTION_OPEN_CHAT) - .putExtra(EXTRA_CHAT_ID, chatId) - .putExtra(EXTRA_MESSAGE_ID, messageId) - .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + private fun openChatIntent( + context: Context, + chatId: Long, + messageId: Int, + accountUserId: Long? = null, + ): Intent = Intent(context, MainActivity::class.java).setAction(ACTION_OPEN_CHAT) + .putExtra(EXTRA_CHAT_ID, chatId).putExtra(EXTRA_MESSAGE_ID, messageId) + .putExtra(EXTRA_ACCOUNT_USER_ID, accountUserId ?: 0L) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) - private fun openAppIntent(context: Context): Intent = - Intent(context, MainActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + private fun openAppIntent(context: Context): Intent = Intent( + context, MainActivity::class.java + ).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) /** Bounded decode for the big-picture style; the file cap is enforced by the coordinator. */ private fun decodePicture(file: File): Bitmap? = diff --git a/app/src/main/java/org/monogram/push/NotificationReadGuard.kt b/app/src/main/java/org/monogram/push/NotificationReadGuard.kt new file mode 100644 index 000000000..0933976a3 --- /dev/null +++ b/app/src/main/java/org/monogram/push/NotificationReadGuard.kt @@ -0,0 +1,37 @@ +package org.monogram.push + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.monogram.core.common.push.PushChannelKind +import org.monogram.core.common.push.PushPayload +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.models.PeerId +import java.util.WeakHashMap + +/** Serializes a committed read with the final unread check and notification post. */ +internal object NotificationReadGuard { + private val locks = WeakHashMap() + + suspend fun withAccount(cache: OfflineWarmup, action: suspend () -> T): T { + val lock = synchronized(locks) { locks.getOrPut(cache) { Mutex() } } + return lock.withLock { action() } + } + + fun appliesTo(payload: PushPayload): Boolean = + !payload.scheduled && payload.channelKind in setOf( + PushChannelKind.Private, PushChannelKind.Group, PushChannelKind.Channel, + ) && listOf("MESSAGE_", "CHAT_MESSAGE_", "CHANNEL_MESSAGE_").any(payload.locKey::startsWith) + + suspend fun isRead( + cache: OfflineWarmup, + chatId: Long?, + messageId: Int?, + topicId: Int? + ): Boolean { + if (chatId == null || chatId == 0L || messageId == null || messageId <= 0) return false + val peer = PeerId(chatId) + val inbox = cache.chat(peer)?.readInboxMaxId ?: 0 + val discussion = topicId?.takeIf { it > 0 }?.let { cache.discussionReadMax(peer, it) } ?: 0 + return messageId <= maxOf(inbox, discussion) + } +} diff --git a/app/src/main/java/org/monogram/push/PushCoordinator.kt b/app/src/main/java/org/monogram/push/PushCoordinator.kt index 3170ec86e..cfd8854be 100644 --- a/app/src/main/java/org/monogram/push/PushCoordinator.kt +++ b/app/src/main/java/org/monogram/push/PushCoordinator.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build +import android.os.SystemClock.elapsedRealtime import android.provider.Settings import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat @@ -16,10 +17,12 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.firebase.FirebaseApp import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -34,6 +37,7 @@ import org.monogram.core.common.push.NotificationLocalStore import org.monogram.core.common.push.PUSH_STATUS_CHOOSE_DISTRIBUTOR import org.monogram.core.common.push.PUSH_STATUS_DISTRIBUTOR_GONE import org.monogram.core.common.push.PUSH_STATUS_NO_DISTRIBUTOR +import org.monogram.core.common.push.PUSH_STATUS_UP_FAILED import org.monogram.core.common.push.PushAction import org.monogram.core.common.push.PushChannelKind import org.monogram.core.common.push.PushPayload @@ -57,6 +61,8 @@ import org.monogram.core.common.push.shouldRefreshDialogsOnWake import org.monogram.core.common.push.shouldSyncOnWake import org.monogram.core.common.push.simplePushEndpoint import org.monogram.core.common.push.webPushRegistration +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.SessionMetadataStore import org.monogram.core.models.Chat import org.monogram.core.models.Folder import org.monogram.core.models.Message @@ -73,22 +79,22 @@ import org.monogram.network.bridge.MtprotoUpdate import org.monogram.network.http.MediaRepository import org.unifiedpush.android.connector.UnifiedPush import java.util.Collections +import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.resume -private val VISUAL_MEDIA_KINDS = - setOf( - "photo", - "video", - "video_note", - "gif", - "sticker", - "sticker_animated", - "sticker_video", - "document" - ) +private val VISUAL_MEDIA_KINDS = setOf( + "photo", + "video", + "video_note", + "gif", + "sticker", + "sticker_animated", + "sticker_video", + "document" +) private class NotificationTiming(private val source: String) { - private val started = android.os.SystemClock.elapsedRealtime() + private val started = elapsedRealtime() private val trace = nextTrace.incrementAndGet() init { @@ -98,12 +104,12 @@ private class NotificationTiming(private val source: String) { fun mark(phase: String, detail: String = "") { if (org.monogram.BuildConfig.DEBUG) android.util.Log.i( "MonogramNotifyTiming", - "trace=$trace source=$source phase=$phase elapsedMs=${android.os.SystemClock.elapsedRealtime() - started} $detail", + "trace=$trace source=$source phase=$phase elapsedMs=${elapsedRealtime() - started} $detail", ) } companion object { - private val nextTrace = java.util.concurrent.atomic.AtomicLong() + private val nextTrace = AtomicLong() } } @@ -113,9 +119,13 @@ class PushCoordinator( private val store: NotificationLocalStore, private val mediaRepository: MediaRepository, @Suppress("unused") private val storeFactory: StoreFactory, - private val sessionStore: org.monogram.core.database.SessionMetadataStore, -) : PushRegistration { + private val sessionStore: SessionMetadataStore, + private val otherAccountUserIds: () -> List = { emptyList() }, + private val sharedPushSecret: () -> ByteArray = store::secret, + private val notificationCache: OfflineWarmup? = null, +) : PushRegistration, AccountPushHandler { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + @Volatile var openChatId: Long? = null @@ -128,18 +138,25 @@ class PushCoordinator( @Volatile private var lastPayload: PushPayload? = null private val notifySettingsMutex = Mutex() + @Volatile var appForeground: Boolean = false + @Volatile var folders: List = emptyList() + @Volatile var exceptions: List = emptyList() + @Volatile var users: NotifySettings = NotifySettings() + @Volatile var chats: NotifySettings = NotifySettings() + @Volatile var broadcasts: NotifySettings = NotifySettings() + @Volatile var chatPhotos: Map = emptyMap() private val restrictedMedia: MutableSet> = @@ -147,12 +164,14 @@ class PushCoordinator( private val mediaLookups: MutableSet> = Collections.synchronizedSet(HashSet()) private val wakeGate = PushWakeGate() private var wakeJob: Job? = null + @Volatile private var notifySettingsLoaded = false private var notifySettingsJob: Job? = null private var repeatJob: Job? = null private var refreshJob: Job? = null private var updatesJob: Job? = null + @Volatile private var accountUserId: Long = 0L @@ -169,6 +188,15 @@ class PushCoordinator( store.notifyExceptions?.let { exceptions = it } } + override fun stop() { + wakeJob?.cancel() + notifySettingsJob?.cancel() + repeatJob?.cancel() + refreshJob?.cancel() + updatesJob?.cancel() + scope.cancel() + } + fun start() { NotificationChannels.ensure(context) NotificationChannels.applyPrefs(context, store) @@ -213,6 +241,7 @@ class PushCoordinator( context, update.message.id.chatId.value, update.message.id.id, notificationText(update.message), store.badgeSettings(), hideMedia = update.message.mediaPreviewRestricted, + accountUserId = accountUserId, ) } @@ -222,6 +251,7 @@ class PushCoordinator( emptySet(), update.maxId, store.badgeSettings(), + accountUserId = accountUserId, ) is MtprotoUpdate.ReadHistoryConfirmed -> NotificationPresenter.dropMessages( @@ -230,18 +260,21 @@ class PushCoordinator( emptySet(), update.maxId, store.badgeSettings(), + accountUserId = accountUserId, ) is MtprotoUpdate.MessagesDeleted -> { val deletedChat = update.chatId if (deletedChat == null) NotificationPresenter.dropUnscopedMessages( context, update.messageIds.toSet(), store.badgeSettings(), + accountUserId = accountUserId, ) else NotificationPresenter.dropMessages( context, deletedChat.value, update.messageIds.toSet(), null, store.badgeSettings(), + accountUserId = accountUserId, ) } @@ -258,21 +291,24 @@ class PushCoordinator( } } + internal fun usesClient(candidate: MtprotoClient): Boolean = client === candidate + override fun setAccountUserId(userId: Long) { accountUserId = userId + if (userId > 0L) NotificationPresenter.clearLegacyNotifications(context) } override fun distributors(): List = unifiedPushDistributors() fun requestPermission(activity: Activity) { if (Build.VERSION.SDK_INT < 33) return - if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != - PackageManager.PERMISSION_GRANTED + if (ContextCompat.checkSelfPermission( + activity, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( - activity, - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - 2301 + activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2301 ) } } @@ -280,9 +316,8 @@ class PushCoordinator( override fun debugState(): PushDebugState = store.debugState(gmsAvailable(), distributor(), permissionGranted()) - override fun gmsAvailable(): Boolean = - GoogleApiAvailability.getInstance() - .isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS + override fun gmsAvailable(): Boolean = GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS override fun distributor(): String = runCatching { UnifiedPush.getAckDistributor(context).orEmpty() @@ -291,9 +326,13 @@ class PushCoordinator( fun permissionGranted(): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() - fun setForeground(value: Boolean) { + override fun setForeground(value: Boolean) { appForeground = value if (value) { + if (!store.notificationsEnabled || !permissionGranted()) { + NotificationPresenter.clear(context, accountUserId) + } + NotificationPresenter.clearDisabled(context, accountUserId) openChatId?.let { dismissVisibleChat(it) } // The app is open and the session is connected: load server mute/preview/folder state. ensureNotifySettings() @@ -311,7 +350,10 @@ class PushCoordinator( notifySettingsJob = scope.launch { // account.getNotifySettings needs a logged-in auth key. Before sign-in the // server answers 401 AUTH_KEY_UNREGISTERED and that used to kill login. - if (client.isLocallyAuthorized() != Outcome.Ok(true)) { + if (sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0" || client.isLocallyAuthorized() != Outcome.Ok( + true + ) + ) { notifySettingsJob = null return@launch } @@ -326,12 +368,16 @@ class PushCoordinator( } override fun onChatRead(chatId: Long) { - if (!NotificationPresenter.suppressVisibleBubble(context, chatId, store.bubblesEnabled)) { + if (!NotificationPresenter.suppressVisibleBubble( + context, chatId, store.bubblesEnabled, accountUserId + ) + ) { dismissChat(chatId) } } override fun onLogout() { + val loggedOutAccount = accountUserId.takeIf { it != 0L } notifySettingsLoaded = false notifySettingsJob?.cancel() notifySettingsJob = null @@ -346,7 +392,7 @@ class PushCoordinator( broadcasts = NotifySettings() store.notifyDefaults = null store.notifyExceptions = null - NotificationPresenter.clear(context) + NotificationPresenter.clear(context, loggedOutAccount) store.lastShownChatId = 0L store.shareChatIds = emptyList() store.clearPushIdentity(clearSecret = true) @@ -363,14 +409,15 @@ class PushCoordinator( } private fun dismissChat(chatId: Long) { - NotificationPresenter.cancel(context, chatId, store.badgeSettings()) + NotificationPresenter.cancel(context, chatId, store.badgeSettings(), accountUserId) if (store.lastShownChatId == chatId) store.lastShownChatId = 0L } override fun requestPermission() { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) - .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra( + Settings.EXTRA_APP_PACKAGE, + context.packageName + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) runCatching { context.startActivity(intent) } } @@ -379,8 +426,20 @@ class PushCoordinator( scheduleRepeat() } + fun requestReregister() { + scope.launch { + try { + reregister() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + AppLog.warn("push", "account registration failed") + } + } + } + override suspend fun reregister() { - NotificationChannels.ensure(context, folders, pruneFolders = true) + NotificationChannels.ensure(context, folders) val plan = planPushProvider( requested = store.providerMode, playServices = gmsAvailable(), @@ -449,9 +508,7 @@ class PushCoordinator( runCatching { val register = { UnifiedPush.register( - context, - instance = instance, - messageForDistributor = "Monogram" + context, instance = instance, messageForDistributor = "Monogram" ) } if (chosen.isNotBlank()) { @@ -486,12 +543,28 @@ class PushCoordinator( val instance = store.pushInstance runCatching { if (instance.isBlank()) UnifiedPush.unregister(context) else UnifiedPush.unregister( - context, - instance + context, instance ) } } + internal fun ownsUnifiedPushInstance(instance: String): Boolean = + accountUserId != 0L && instance == "account:$accountUserId" && store.pushInstance == instance + + internal fun onUnifiedPushRegistrationFailed() { + store.setLastRegister(PUSH_STATUS_UP_FAILED) + } + + internal fun onUnifiedPushUnregistered() { + val type = store.tokenType() + if (type != PushTokenType.Simple.code && type != PushTokenType.WebPush.code) return + val mode = store.providerMode + store.clearPushIdentity() + store.setLastRegister( + if (mode == PushProviderMode.ForceUnifiedPush) PUSH_STATUS_DISTRIBUTOR_GONE else NO_PUSH_STATUS, + ) + } + fun onWebPushEndpoint(endpoint: String, p256dh: String?, auth: String?) { scope.launch { if (!acceptsUnifiedPushEndpoint()) return@launch @@ -516,11 +589,11 @@ class PushCoordinator( unifiedPushAvailable = unifiedPushAvailable(), ) - fun onFcmToken(token: String) { + override fun onFcmToken(token: String, onComplete: (() -> Unit)?) { scope.launch { if (!acceptsFcmToken(currentPlan().register)) return@launch registerToken(PushTokenType.Fcm, token) - } + }.invokeOnCompletion { onComplete?.invoke() } } private suspend fun registerToken(type: PushTokenType, token: String) { @@ -531,16 +604,23 @@ class PushCoordinator( nextToken = token, ) change.previous?.let { (previousType, previousToken) -> - runCatching { client.unregisterDevice(tokenType = previousType, token = previousToken) } + runCatching { + client.unregisterDevice( + tokenType = previousType, + token = previousToken, + otherUids = otherAccountUserIds().filter { it != accountUserId }, + ) + } } if (change.unregisterUnifiedPush) unregisterUnifiedPushConnector() store.setToken(type, token) val result = client.registerDevice( tokenType = type, token = token, - secret = if (type == PushTokenType.Fcm) store.secret() else ByteArray(0), + secret = if (type == PushTokenType.Fcm) sharedPushSecret() else ByteArray(0), noMuted = store.noMuted, appSandbox = store.appSandbox, + otherUids = otherAccountUserIds().filter { it != accountUserId }, ) store.setLastRegister( when (result) { @@ -549,12 +629,9 @@ class PushCoordinator( }, ) AppLog.api( - "registerDevice", - "type=${type.code} result=${ + "registerDevice", "type=${type.code} result=${ store.debugState( - gmsAvailable(), - distributor(), - permissionGranted() + gmsAvailable(), distributor(), permissionGranted() ).lastRegister }" ) @@ -566,50 +643,60 @@ class PushCoordinator( scope.launch { handlePayload(json, wake = false, joinWake = false) } } - fun handleFcm(payload: String?, onComplete: (() -> Unit)? = null) { + override fun handleFcm(payload: String?, onComplete: (() -> Unit)?) { handleIncoming(fcm = true, body = payload?.encodeToByteArray(), onComplete = onComplete) } + override fun decryptFcmPayload(cipher: String): Outcome { + val shared = client.decryptPushPayload(sharedPushSecret(), cipher) + return if (shared is Outcome.Ok) shared else client.decryptPushPayload( + store.secret(), cipher + ) + } + + override fun handleDecryptedFcmPayload(json: String, onComplete: (() -> Unit)?) { + val timing = NotificationTiming("fcm-routed") + scope.launch { + handlePayload(parsePushPayload(json), wake = true, joinWake = true, timing = timing) + }.invokeOnCompletion { onComplete?.invoke() } + } + fun handleIncoming(fcm: Boolean, body: ByteArray?, onComplete: (() -> Unit)? = null) { val timing = NotificationTiming(if (fcm) "fcm" else "unifiedpush") scope.launch { - try { - when (val incoming = normalizeIncomingBody(fcm, body)) { - IncomingPush.Wake -> { - if (!fcm) ensureNotifySettings() - wakeFetchAndJoin() - } - - is IncomingPush.Decrypt -> when (val decrypted = - client.decryptPushPayload(store.secret(), incoming.cipher)) { - is Outcome.Ok -> when (val plain = normalizeDecrypted(decrypted.value)) { - is IncomingPush.Present -> handlePayload( - plain.json, - wake = true, - joinWake = true, - timing = timing, - ) + when (val incoming = normalizeIncomingBody(fcm, body)) { + IncomingPush.Wake -> { + if (!fcm) ensureNotifySettings() + wakeFetchAndJoin() + } - else -> wakeFetchAndJoin() - } + is IncomingPush.Decrypt -> when (val decrypted = + decryptFcmPayload(incoming.cipher)) { + is Outcome.Ok -> when (val plain = normalizeDecrypted(decrypted.value)) { + is IncomingPush.Present -> handlePayload( + plain.json, + wake = true, + joinWake = true, + timing = timing, + ) - is Outcome.Err -> { - AppLog.warn("push", "decrypt failed") - wakeFetchAndJoin() - } + else -> wakeFetchAndJoin() } - is IncomingPush.Present -> handlePayload( - incoming.json, - wake = true, - joinWake = true, - timing = timing, - ) + is Outcome.Err -> { + AppLog.warn("push", "decrypt failed") + wakeFetchAndJoin() + } } - } finally { - onComplete?.invoke() + + is IncomingPush.Present -> handlePayload( + incoming.json, + wake = true, + joinWake = true, + timing = timing, + ) } - } + }.invokeOnCompletion { onComplete?.invoke() } } fun handleWake() { @@ -656,6 +743,8 @@ class PushCoordinator( senderId = message.senderId?.value, title = chat?.title.orEmpty(), kind = chat?.let { notificationPeerKind(it) } ?: PushChannelKind.Private, + topicId = if (message.forumTopic) message.replyToTopId + ?: message.replyToMsgId else null, ) ?: return val timing = NotificationTiming("mtproto") scope.launch { handlePayload(payload, wake = false, joinWake = false, timing = timing) } @@ -700,7 +789,8 @@ class PushCoordinator( chatId, ids.toSet(), upTo = null, - badge = store.badgeSettings() + badge = store.badgeSettings(), + accountUserId = accountUserId, ) } maybeWake() @@ -714,6 +804,7 @@ class PushCoordinator( dropIds = emptySet(), upTo = historyReadUpTo(payload.messageId ?: 0, payload.maxId ?: 0), badge = store.badgeSettings(), + accountUserId = accountUserId, ) } maybeWake() @@ -728,8 +819,8 @@ class PushCoordinator( if (!notifySettingsLoaded && needsSettings) { withTimeoutOrNull(15_000L) { notifySettingsJob?.join() } } - if (!notifySettingsLoaded && - (!store.hasNotifySettingsCache || store.mutedFolders().isNotEmpty()) + if (!notifySettingsLoaded && (!store.hasNotifySettingsCache || store.mutedFolders() + .isNotEmpty()) ) { maybeWake() return @@ -744,10 +835,8 @@ class PushCoordinator( } timing.mark("policy", "cached=${store.hasNotifySettingsCache} wait=$needsSettings") val now = (System.currentTimeMillis() / 1000L).toInt() - val mutedFolderChats = folders - .filter { it.id in store.mutedFolders() } - .flatMap { folderMemberIds(it, knownChats) } - .toSet() + val mutedFolderChats = folders.filter { it.id in store.mutedFolders() } + .flatMap { folderMemberIds(it, knownChats) }.toSet() val decision = decideNotification( payload, policy(mutedFolderChats), @@ -765,8 +854,10 @@ class PushCoordinator( if (!shown.show) { AppLog.api("notify", "suppressed loc=${payload.locKey}") } - present(payload, shown, folderId, timing) - if (decision.show) { + val delivered = withUnreadPayload(payload) { + present(payload, shown, folderId, timing) + } + if (decision.show && delivered) { lastPayload = payload payload.chatId?.let { store.lastShownChatId = it } scheduleRepeat() @@ -776,9 +867,15 @@ class PushCoordinator( } } - fun handleNotificationAction(intent: Intent, done: () -> Unit) { + override fun handleNotificationAction(intent: Intent, done: () -> Unit) { scope.launch { - try { + run { + val actionAccountId = intent.getLongExtra( + NotificationPresenter.EXTRA_ACCOUNT_USER_ID, + 0L, + ).takeIf { it > 0L } + if (actionAccountId != null && actionAccountId != accountUserId) return@launch + if (!store.notificationsEnabled || !permissionGranted()) return@launch if (intent.action == NotificationPresenter.ACTION_DISMISS) { val dismissedChat = intent.getLongExtra(NotificationPresenter.EXTRA_CHAT_ID, 0L) val through = intent.getIntExtra(NotificationPresenter.EXTRA_MESSAGE_ID, 0) @@ -788,31 +885,35 @@ class PushCoordinator( emptySet(), through.takeIf { it > 0 } ?: Int.MAX_VALUE, store.badgeSettings(), + accountUserId = accountUserId, + ) + NotificationPresenter.refreshSummary( + context, + store.badgeSettings(), + accountUserId = accountUserId, ) - NotificationPresenter.refreshSummary(context, store.badgeSettings()) return@launch } val chatId = intent.getLongExtra(NotificationPresenter.EXTRA_CHAT_ID, 0L) val messageId = intent.getIntExtra(NotificationPresenter.EXTRA_MESSAGE_ID, 0) + if (!store.notificationsEnabled || !permissionGranted() || chatId == 0L || !NotificationPresenter.notificationActionsEnabled( + context, chatId, accountUserId + ) + ) return@launch AppLog.api("notify", "action=${intent.action} chat=$chatId") if (chatId == 0L) return@launch when (intent.action) { NotificationPresenter.ACTION_REPLY -> { val text = RemoteInput.getResultsFromIntent(intent) - ?.getCharSequence(NotificationPresenter.KEY_TEXT_REPLY) - ?.toString() - ?.trim() - .orEmpty() + ?.getCharSequence(NotificationPresenter.KEY_TEXT_REPLY)?.toString() + ?.trim().orEmpty() if (text.isEmpty()) return@launch val replyTo = messageId.takeIf { it > 0 } ?: 0 - val sent = client.connect() is Outcome.Ok && - client.sendText( - PeerId(chatId), - text, - replyToMsgId = replyTo - ) is Outcome.Ok + val sent = client.connect() is Outcome.Ok && client.sendText( + PeerId(chatId), text, replyToMsgId = replyTo + ) is Outcome.Ok if (sent) dismissChat(chatId) - else NotificationPresenter.showNotSent(context, chatId) + else NotificationPresenter.showNotSent(context, chatId, accountUserId) } NotificationPresenter.ACTION_MARK_READ -> { @@ -820,10 +921,12 @@ class PushCoordinator( messageId, intent.getIntExtra(NotificationPresenter.EXTRA_MAX_ID, 0), ) - val read = client.connect() is Outcome.Ok && - client.readHistory(PeerId(chatId), upTo) is Outcome.Ok + val read = client.connect() is Outcome.Ok && client.readHistory( + PeerId(chatId), + upTo + ) is Outcome.Ok if (read) dismissChat(chatId) - else NotificationPresenter.showNotSent(context, chatId) + else NotificationPresenter.showNotSent(context, chatId, accountUserId) } NotificationPresenter.ACTION_MUTE -> { @@ -835,12 +938,10 @@ class PushCoordinator( it.chatId.value == chatId && it.topicId == null }?.settings ?: NotifySettings() val settings = current.copy(muteUntil = until) - val muted = client.connect() is Outcome.Ok && - client.updateNotifySettings( - "peer", - settings, - PeerId(chatId) - ) is Outcome.Ok + val muted = + client.connect() is Outcome.Ok && client.updateNotifySettings( + "peer", settings, PeerId(chatId) + ) is Outcome.Ok if (muted) { store.cacheNotifySettings("peer", chatId, settings) dismissChat(chatId) @@ -848,10 +949,8 @@ class PushCoordinator( } } } - } finally { - done() } - } + }.invokeOnCompletion { done() } } private fun rememberChatPhotos(chats: List) { @@ -867,6 +966,33 @@ class PushCoordinator( return mediaRepository.cachedAvatar(PeerId(chatId)) } + private suspend fun withUnreadPayload(payload: PushPayload, post: () -> Unit): Boolean { + val cache = notificationCache + if (cache == null || !NotificationReadGuard.appliesTo(payload)) { + post() + return true + } + return NotificationReadGuard.withAccount(cache) { + val alreadyRead = try { + NotificationReadGuard.isRead( + cache, + payload.chatId, + payload.messageId, + payload.topicId + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("notify", "cached read state unavailable") + false + } + if (alreadyRead) false else { + post() + true + } + } + } + private fun present( payload: PushPayload, decision: org.monogram.core.common.push.NotificationDecision, @@ -877,7 +1003,10 @@ class PushCoordinator( val cached = if (chatId != 0L) avatarFile(chatId) else null val mode = if (chatId != 0L) store.peerMode(chatId) else null val badge = store.badgeSettings() - NotificationPresenter.show(context, payload, decision, folderId, cached, mode, badge) + NotificationPresenter.show( + context, payload, decision, folderId, cached, mode, badge, + accountUserId = accountUserId, + ) timing.mark("post_requested") if (chatId == 0L || !decision.show) return if (decision.preview) { @@ -895,12 +1024,23 @@ class PushCoordinator( completed = true return@withTimeoutOrNull } - val file = mediaThumbFile(chatId, messageId) ?: return@withTimeoutOrNull + val file = mediaThumbFile(chatId, messageId, message.mediaKind) + ?: return@withTimeoutOrNull if ((chatId to messageId) in restrictedMedia) return@withTimeoutOrNull - NotificationPresenter.show( - context, payload, decision, folderId, avatarFile(chatId), mode, badge, - quiet = true, pictureFile = file, - ) + withUnreadPayload(payload) { + NotificationPresenter.show( + context, + payload, + decision, + folderId, + avatarFile(chatId), + mode, + badge, + quiet = true, + pictureFile = file, + accountUserId = accountUserId, + ) + } timing.mark("image_post_requested") completed = true } @@ -918,16 +1058,13 @@ class PushCoordinator( } } avatarFile(chatId)?.let { file -> - NotificationPresenter.show( - context, - payload, - decision, - folderId, - file, - mode, - badge, - quiet = true - ) + withUnreadPayload(payload) { + NotificationPresenter.show( + context, payload, decision, folderId, file, mode, badge, + quiet = true, accountUserId = accountUserId, + ) + } + return@launch } val key = chatPhotos[chatId] ?: peerAvatarCacheKey(PeerId(chatId)) @@ -935,16 +1072,13 @@ class PushCoordinator( is Outcome.Ok -> result.value is Outcome.Err -> null } ?: return@launch - NotificationPresenter.show( - context, - payload, - decision, - folderId, - file, - mode, - badge, - quiet = true - ) + withUnreadPayload(payload) { + NotificationPresenter.show( + context, payload, decision, folderId, file, mode, badge, + quiet = true, accountUserId = accountUserId, + ) + } + } } @@ -966,30 +1100,39 @@ class PushCoordinator( * Thumbnail for a media push, so the shade shows the picture instead of only "sent a photo". * Bounded by a 2 MiB file cap and cached per message in the app cache dir. */ - private suspend fun mediaThumbFile(chatId: Long, messageId: Int): java.io.File? { + private suspend fun mediaThumbFile( + chatId: Long, messageId: Int, mediaKind: String? + ): java.io.File? { if (chatId == 0L || messageId <= 0) return null - val dir = java.io.File(context.cacheDir, "notif-thumbs").apply { mkdirs() } - val file = java.io.File(dir, "thumb_${chatId}_$messageId.jpg") - if (!file.isFile) { - val downloaded = - client.downloadMessageThumb(PeerId(chatId), messageId, file.absolutePath) - if (downloaded is Outcome.Err) { - // The avatar repaint downloads at the same time and the session can reject one of the - // two ("download media failed ... unclassified"); one short retry recovers it. - delay(750) - if (client.downloadMessageThumb( - PeerId(chatId), - messageId, - file.absolutePath - ) is Outcome.Err - ) { - val inline = client.peekMessageInlineThumb(PeerId(chatId), messageId) - if (inline == null || inline.isEmpty() || inline.size > 2 * 1024 * 1024) return null - file.writeBytes(inline) - } + val key = "notification:$chatId:$messageId:thumb" + val peer = PeerId(chatId) + // Notification previews must remain attributed to their chat, including GIFs. + val cacheKind = mediaKind.takeUnless { it == "gif" } + suspend fun download() = mediaRepository.ensureIndexedMedia( + peerId = peer, + messageId = messageId, + cacheKey = key, + thumb = true, + mediaKind = cacheKind, + ) + + var result = download() + if (result is Outcome.Err) { + delay(750) + result = download() + } + val file = when (result) { + is Outcome.Ok -> result.value + is Outcome.Err -> { + val inline = client.peekMessageInlineThumb(peer, messageId) + if (inline == null || inline.isEmpty() || inline.size > 2 * 1024 * 1024) return null + mediaRepository.cacheThumbnail(key, inline, chatId, cacheKind) ?: return null } } - if (!file.isFile || file.length() <= 0L || file.length() > 2L * 1024 * 1024) return null + if (!file.isFile || file.length() <= 0L || file.length() > 2L * 1024 * 1024) { + mediaRepository.removeCachedFile(key) + return null + } return file } @@ -1007,12 +1150,14 @@ class PushCoordinator( val mutedFolders = folders.filter { it.id in store.mutedFolders() } .flatMap { folderMemberIds(it, knownChats) }.toSet() val decision = decideNotification( - payload.copy(mention = false), policy(mutedFolders), - (System.currentTimeMillis() / 1000L).toInt(), false + payload.copy(mention = false), + policy(mutedFolders), + (System.currentTimeMillis() / 1000L).toInt(), + false ) if (!decision.show || !decision.sound) continue // Re-alerts the collapsed chat batch; stops when it left the shade. - if (!NotificationPresenter.realert(context, chatId)) return@launch + if (!NotificationPresenter.realert(context, chatId, accountUserId)) return@launch } } } @@ -1050,7 +1195,9 @@ class PushCoordinator( val defaults = store.notifyDefaults ?: NotifyDefaults(users, chats, broadcasts) val rows = store.notifyExceptions ?: exceptions return store.policy( - defaults.users, defaults.chats, defaults.broadcasts, + defaults.users, + defaults.chats, + defaults.broadcasts, rows.filter { it.topicId == null && it.chatId.value != 0L } .associate { it.chatId.value to it.settings }, mutedFolderChats @@ -1067,11 +1214,15 @@ class PushCoordinator( NotificationPresenter.dropMessages( context, chatId, emptySet(), Int.MAX_VALUE, store.badgeSettings(), topicId, + accountUserId = accountUserId, ) return } if (knownChats.any { it.id.value == chatId && it.isForum }) return - if (!NotificationPresenter.suppressVisibleBubble(context, chatId, store.bubblesEnabled)) { + if (!NotificationPresenter.suppressVisibleBubble( + context, chatId, store.bubblesEnabled, accountUserId + ) + ) { dismissChat(chatId) } } @@ -1100,13 +1251,12 @@ class PushCoordinator( is Outcome.Ok -> rememberChatPhotos(result.value) is Outcome.Err -> Unit } - notifySettingsLoaded = userResult is Outcome.Ok && chatResult is Outcome.Ok && - broadcastResult is Outcome.Ok && exceptionResult is Outcome.Ok && - folderResult is Outcome.Ok && dialogResult is Outcome.Ok + notifySettingsLoaded = + userResult is Outcome.Ok && chatResult is Outcome.Ok && broadcastResult is Outcome.Ok && exceptionResult is Outcome.Ok && folderResult is Outcome.Ok && dialogResult is Outcome.Ok AppLog.api( "notify", "settings loaded folders=${folders.size} exceptions=${exceptions.size} mutedFolders=${store.mutedFolders().size}", ) - NotificationChannels.ensure(context, folders, pruneFolders = true) + NotificationChannels.ensure(context, folders) } } diff --git a/app/src/main/java/org/monogram/push/SharedPushSecret.kt b/app/src/main/java/org/monogram/push/SharedPushSecret.kt new file mode 100644 index 000000000..fc66e84cc --- /dev/null +++ b/app/src/main/java/org/monogram/push/SharedPushSecret.kt @@ -0,0 +1,27 @@ +package org.monogram.push + +import android.content.Context +import android.util.Base64 +import java.security.SecureRandom + +class SharedPushSecret(context: Context) { + private val prefs = + context.applicationContext.getSharedPreferences("shared_push_secret", Context.MODE_PRIVATE) + + @Synchronized + fun get(): ByteArray { + val stored = prefs.getString("secret", null) + if (stored != null) { + return Base64.decode(stored, Base64.NO_WRAP).also { + require(it.size == 256) { "Invalid push secret" } + } + } + val secret = ByteArray(256).also { SecureRandom().nextBytes(it) } + check( + prefs.edit().putString("secret", Base64.encodeToString(secret, Base64.NO_WRAP)).commit() + ) { + "Unable to persist push secret" + } + return secret + } +} \ No newline at end of file diff --git a/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt b/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt new file mode 100644 index 000000000..09c92efd2 --- /dev/null +++ b/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt @@ -0,0 +1,204 @@ +package org.monogram.root + +import kotlinx.coroutines.CancellationException +import org.monogram.core.common.AppLog +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.RetainedMediaSource +import org.monogram.core.database.SessionMetadataStore +import org.monogram.core.database.entity.UpdateCursorEntity +import org.monogram.network.bridge.DurableUpdateBatch +import org.monogram.network.bridge.MtprotoUpdate +import org.monogram.push.NotificationReadGuard + +internal class AccountDurableUpdateSink( + private val cache: OfflineWarmup, +) { + suspend fun commit(batch: DurableUpdateBatch): Boolean { + val cursor = batch.cursor?.let { + UpdateCursorEntity( + SessionMetadataStore.KEY_PRIMARY_CURSOR, + it.pts, + it.qts, + it.date, + it.seq, + ) + } + val releasedSources = mutableListOf() + val committed = NotificationReadGuard.withAccount(cache) { + cache.applyDurableBatch( + batch.accountGeneration, + batch.batchId, + cursor, + batch.channelCursors, + ) { + val receipts = + batch.updates.filterIsInstance() + val receiptIds = receipts.map { it.randomId }.toSet() + val media = if (receiptIds.isEmpty()) { + emptyList() + } else { + cache.outgoingMediaSends().filter { send -> + send.items.any { it.randomId in receiptIds } + } + } + val mediaIds = media.flatMap { send -> send.items.map { it.randomId } }.toSet() + var index = 0 + while (index < batch.updates.size) { + val update = batch.updates[index] + if (update is MtprotoUpdate.NewMessage) { + val messages = mutableListOf(update.message) + index++ + while (index < batch.updates.size && messages.size < 100) { + val next = batch.updates[index] as? MtprotoUpdate.NewMessage ?: break + messages += next.message + index++ + } + cache.applyIncomingMessages( + messages, batch.accountGeneration, batch.batchId + ) + } else { + index++ + if (update is MtprotoUpdate.OutgoingMessageAcknowledged && update.randomId in mediaIds) continue + apply(update, batch.accountGeneration, batch.batchId) + } + } + media.forEach { send -> + val ids = send.items.map { it.randomId }.toSet() + cache.applyOutgoingMediaResults( + send.batch.batchId, + receipts.filter { it.randomId in ids }.map { it.randomId to it.result }, + cache.outgoingAccountEpoch(), + batch.accountGeneration, + batch.batchId, + ) + releasedSources += send.items.map { it.source } + } + } + } + if (committed && releasedSources.isNotEmpty()) { + try { + cache.outgoingMediaFiles?.release(releasedSources) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("outgoing", "retained media cleanup deferred") + } + } + return committed + } + + private suspend fun apply( + update: MtprotoUpdate, + generation: Long, + order: Long, + ) { + when (update) { + is MtprotoUpdate.NewMessage -> { + cache.applyIncomingMessage( + update.message, + generation, + order, + ) + } + + is MtprotoUpdate.OutgoingMessageAcknowledged -> { + cache.applyOutgoingResult( + update.randomId, + update.result, + generation, + order, + ) + } + + is MtprotoUpdate.ScheduledMessageChanged -> { + cache.applyScheduledMessage(update.message) + } + + is MtprotoUpdate.ScheduledMessagesDeleted -> { + cache.deleteScheduledMessages( + update.chatId, + update.messageIds, + ) + } + + is MtprotoUpdate.DraftChanged -> { + cache.applyCloudDraft(update.draft) + } + + is MtprotoUpdate.MessageEdited -> { + cache.applyMessageEdit(update.message) + } + + is MtprotoUpdate.MessagesDeleted -> { + cache.deleteMessages( + update.chatId, + update.messageIds, + ) + } + + is MtprotoUpdate.MessageReactions -> { + cache.applyReactions( + update.chatId, + update.messageId, + update.reactionsJson, + ) + } + + is MtprotoUpdate.ReadOutbox -> { + cache.applyOutboxRead(update.chatId, update.maxId) + } + + is MtprotoUpdate.DiscussionInbox -> { + cache.applyDiscussionRead( + update.channelId, + update.topMessageId, + update.readMaxId, + ) + } + + is MtprotoUpdate.ReadInbox -> { + cache.applyInboxRead( + update.chatId, + update.maxId, + update.stillUnread, + ) + } + + is MtprotoUpdate.ChatsChanged -> { + cache.upsertChats(update.chats) + } + + is MtprotoUpdate.Ignored -> { + when { + update.kind == "difference_too_long" -> { + cache.invalidateCachedHistories() + } + + update.kind == "channel_difference_too_long" || update.kind.startsWith("channel_difference_too_long:") -> { + val chatId = update.kind.substringAfter(':', "").toLongOrNull() + require(chatId != null && chatId != 0L) { "Invalid channel history recovery marker" } + cache.invalidateCachedHistory(chatId) + } + } + } + + is MtprotoUpdate.UnreadMentionsDelta -> { + cache.addUnreadMentions( + update.chatId, + update.delta, + ) + } + + is MtprotoUpdate.UnreadReactionsDelta -> { + cache.addUnreadReactions( + update.chatId, + update.delta, + ) + } + + else -> { + Unit + } + } + } +} diff --git a/app/src/main/java/org/monogram/root/CloudDraftSync.kt b/app/src/main/java/org/monogram/root/CloudDraftSync.kt index 1616d0838..ffe085940 100644 --- a/app/src/main/java/org/monogram/root/CloudDraftSync.kt +++ b/app/src/main/java/org/monogram/root/CloudDraftSync.kt @@ -1,6 +1,5 @@ package org.monogram.root -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -10,29 +9,32 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.monogram.AccountBackgroundWork import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome import org.monogram.core.common.telegram.TelegramError import org.monogram.core.database.OfflineWarmup import org.monogram.core.models.CloudDraft import org.monogram.core.models.PeerId +import java.util.concurrent.atomic.AtomicBoolean internal class CloudDraftSync( private val cache: OfflineWarmup, private val fetch: suspend () -> Outcome>, private val save: suspend (CloudDraft) -> Outcome, -) { +) : AccountBackgroundWork { private val refreshPending = AtomicBoolean(true) private val retryRejected = AtomicBoolean(false) private val rejected = mutableMapOf, Long>() private val wake = Channel(Channel.CONFLATED) private var job: Job? = null - fun start(scope: CoroutineScope) { + override fun isRunning(): Boolean = job?.isActive == true + + override fun start(scope: CoroutineScope) { if (job?.isActive == true) return job = scope.launch { coroutineScope { @@ -75,13 +77,15 @@ internal class CloudDraftSync( } } - suspend fun stop() { + override suspend fun stop() { val running = job job = null running?.cancelAndJoin() cache.draftSync.reset() } + override fun resume() = requestRefresh() + fun requestRefresh() { refreshPending.set(true) retryRejected.set(true) @@ -102,14 +106,12 @@ internal class CloudDraftSync( is Outcome.Ok -> { val returned = result.value.associateBy { it.chatId to it.threadId } for ((key, draft) in returned) cache.applyCloudDraft( - draft, - before[key]?.revision ?: 0 + draft, before[key]?.revision ?: 0 ) for ((key, state) in before) { if (key !in returned) cache.applyCloudDraft( CloudDraft( - key.first, - key.second + key.first, key.second ), state.revision ) } @@ -130,9 +132,7 @@ internal class CloudDraftSync( when (val result = monitored(key) { save(state.draft).also { if (it is Outcome.Ok) cache.acknowledgeCloudDraft( - state.draft.chatId, - state.draft.threadId, - state.revision + state.draft.chatId, state.draft.threadId, state.revision ) } }) { @@ -145,8 +145,7 @@ internal class CloudDraftSync( TelegramError.Kind.Auth, TelegramError.Kind.Session, TelegramError.Kind.Internal - ) || - error.httpCode in 500..599 || (error.retryAfterSeconds ?: 0) > 0 + ) || error.httpCode in 500..599 || (error.retryAfterSeconds ?: 0) > 0 ) { cache.draftSync.fail(error) refreshPending.set(true) @@ -160,8 +159,7 @@ internal class CloudDraftSync( } private suspend fun monitored( - context: Pair?, - block: suspend () -> Outcome + context: Pair?, block: suspend () -> Outcome ): Outcome { val token = cache.draftSync.begin(context) var error: TelegramError? = null diff --git a/app/src/main/java/org/monogram/root/OutgoingTextSync.kt b/app/src/main/java/org/monogram/root/OutgoingTextSync.kt index a564160b5..6f25b3f36 100644 --- a/app/src/main/java/org/monogram/root/OutgoingTextSync.kt +++ b/app/src/main/java/org/monogram/root/OutgoingTextSync.kt @@ -15,10 +15,11 @@ import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.monogram.AccountBackgroundWork import org.monogram.core.common.Outcome import org.monogram.core.common.telegram.TelegramError -import org.monogram.core.database.StoredPayloadException import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.StoredPayloadException import org.monogram.core.database.decodeContactSend import org.monogram.core.database.decodeLocationSend import org.monogram.core.database.entity.OutgoingMediaSend @@ -128,13 +129,15 @@ internal class OutgoingTextSync( private val wallMillis: () -> Long = System::currentTimeMillis, private val elapsedMillis: () -> Long = { System.nanoTime() / 1_000_000 }, private val sendMedia: suspend (OutgoingMediaSend) -> Outcome = { Outcome.Err("unsupported") }, -) { +) : AccountBackgroundWork { private val wake = Channel(Channel.CONFLATED) private var job: Job? = null private var paused = false private var blockedUntil = 0L - fun start(scope: CoroutineScope) { + override fun isRunning(): Boolean = job?.isActive == true + + override fun start(scope: CoroutineScope) { if (job?.isActive == true) return paused = false job = scope.launch { @@ -193,7 +196,9 @@ internal class OutgoingTextSync( paused = false; wake.trySend(Unit) } - suspend fun stop() { + override fun resume() = requestResume() + + override suspend fun stop() { val running = job job = null running?.cancelAndJoin() @@ -274,7 +279,7 @@ internal class OutgoingTextSync( claim = { cache.claimOutgoingTextSend(row.randomId) }, markUncertain = { cache.markOutgoingTextSendUncertain(row.randomId) }, markFailed = { cache.markOutgoingTextSendFailed(row.randomId) }, - exists = { cache.outgoingTextSends().any { it.randomId == row.randomId } }, + exists = { cache.hasOutgoingTextSend(row.randomId) }, send = { when (val result = send(row)) { is Outcome.Ok -> Outcome.Ok(Unit) @@ -290,9 +295,7 @@ internal class OutgoingTextSync( claim = { cache.claimOutgoingMediaSend(row.batch.batchId) }, markUncertain = { cache.markOutgoingMediaSendUncertain(row.batch.batchId) }, markFailed = { cache.markOutgoingMediaSendFailed(row.batch.batchId) }, - exists = { - cache.outgoingMediaSends().any { it.batch.batchId == row.batch.batchId } - }, + exists = { cache.hasOutgoingMediaSend(row.batch.batchId) }, send = { sendMedia(row) }) } return (text + media).sortedWith(compareBy({ it.date }, { it.id })) diff --git a/app/src/main/java/org/monogram/root/RootComponent.kt b/app/src/main/java/org/monogram/root/RootComponent.kt index 1f41f4343..435d8f2b6 100644 --- a/app/src/main/java/org/monogram/root/RootComponent.kt +++ b/app/src/main/java/org/monogram/root/RootComponent.kt @@ -1,6 +1,5 @@ package org.monogram.root -import android.content.res.Resources.getSystem import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.router.stack.StackNavigation @@ -25,18 +24,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.monogram.BuildConfig +import org.monogram.MonogramApp.PendingAccountAuthorization import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome -import org.monogram.core.common.PerfLog -import org.monogram.core.common.RuntimePressure import org.monogram.core.common.push.NotificationLocalStore import org.monogram.core.common.push.PushRegistration import org.monogram.core.common.telegram.TelegramLink import org.monogram.core.common.telegram.parseTelegramLink import org.monogram.core.database.OfflineWarmup -import org.monogram.core.database.RetainedMediaSource import org.monogram.core.database.SessionMetadataStore -import org.monogram.core.database.entity.UpdateCursorEntity import org.monogram.core.markup.NativeMarkupParser import org.monogram.core.models.AccountState import org.monogram.core.models.AppUpdate @@ -47,20 +43,23 @@ import org.monogram.core.ui.DownloadSettings import org.monogram.core.ui.ImageCache import org.monogram.core.ui.media.MediaPlaybackHolder import org.monogram.feature.auth.AuthComponent +import org.monogram.feature.chats.AccountMenuItem import org.monogram.feature.chats.ChatsComponent import org.monogram.feature.dialog.DialogComponent -import org.monogram.feature.dialog.SelectedTargetWarmup +import org.monogram.feature.dialog.clearDialogMemory import org.monogram.feature.dialog.scheduled.ScheduledQueueComponent +import org.monogram.feature.dialog.ui.stopAudioMessagePlayback import org.monogram.feature.folders.FoldersComponent import org.monogram.feature.profile.ProfileComponent import org.monogram.feature.settings.AppUpdateController +import org.monogram.feature.settings.SettingsAccountDetails import org.monogram.feature.settings.SettingsComponent +import org.monogram.feature.settings.SettingsPage import org.monogram.feature.settings.ui.ProxySession import org.monogram.network.bridge.BridgedMtprotoClient import org.monogram.network.bridge.DurableUpdateBatch import org.monogram.network.bridge.MtprotoClient import org.monogram.network.bridge.MtprotoUpdate -import org.monogram.network.http.MediaPriority import org.monogram.network.http.MediaRepository class RootComponent( @@ -71,6 +70,7 @@ class RootComponent( private val sessionStore: SessionMetadataStore?, private val mediaRepository: MediaRepository?, startOnHome: Boolean, + accountSwitchDestination: Config? = null, private val accountState: AccountState = AccountState(), private val pushRegistration: PushRegistration? = null, private val notificationLocal: NotificationLocalStore? = null, @@ -79,51 +79,36 @@ class RootComponent( private val bubbleChatId: Long? = null, private val liveLocations: LiveLocationRuntime? = null, private val onRootBack: () -> Unit = {}, + private val onAccountAuthorized: suspend (AuthSession) -> Unit = {}, + private val onAccountLoggedOut: suspend () -> Unit = {}, + private val onAccountWorkStop: suspend () -> Unit = {}, + private val onAccountWorkResume: () -> Unit = {}, + private val onBeginAddAccount: suspend () -> PendingAccountAuthorization = { error("Add account unavailable") }, + private val onAdditionalAccountAuthorized: suspend (AuthSession, PendingAccountAuthorization) -> Unit = { _, _ -> }, + private var accountUserIds: List = emptyList(), + private var accountMenuItems: List = emptyList(), + private var accountNeedsSignInIds: Set = emptySet(), + private var activeAccountUserId: Long? = null, + private var activeAccountDcId: Int? = null, + private val loadAccountMenuItems: (suspend () -> List)? = null, + private val loadAccountDetails: suspend (Long, suspend (SettingsAccountDetails) -> Unit) -> Unit = { _, _ -> }, + private val onSwitchAccount: suspend (Long) -> Unit = {}, + private val onRemoveAccount: suspend (Long) -> Unit = {}, ) : ComponentContext by componentContext { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val targetWarmup = SelectedTargetWarmup( - scope = scope, - tablet = { - getSystem().configuration.smallestScreenWidthDp >= 600 - }, - underPressure = { - val storagePressure = (mediaRepository?.inflightBytes() ?: 0L) > 32L * 1024 * 1024 - val runtime = Runtime.getRuntime() - val free = runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory() - storagePressure || free < 32L * 1024 * 1024 || RuntimePressure.blocked() - }, - load = { peer, anchor, limit, priority -> - val usedBefore = mediaRepository?.usedByteCount() ?: 0L - val page = if (priority >= MediaPriority.VISIBLE) { - client.getHistoryPage(PeerId(peer), limit = limit, offsetId = anchor) - } else { - client.getHistoryPageBackground(PeerId(peer), limit = limit, offsetId = anchor) - } - if (page is Outcome.Ok) warmup?.cacheHistoryPage(PeerId(peer), page.value) - val used = (mediaRepository?.usedByteCount() ?: 0L) - usedBefore - PerfLog.event( - "target_warmup", "priority=$priority used_bytes=$used" - ) - }, - ) + private var pendingAdditionalAuthorization: PendingAccountAuthorization? = null - fun noteFirstFrame() { - targetWarmup.onFirstFrame() + fun beginAddAccount() { + scope.launch { + pendingAdditionalAuthorization = onBeginAddAccount() + navigation.navigate { it + Config.AddAccount } + } } private val navigation = StackNavigation() private var expiringSession = false - private val draftSync = - warmup?.let { CloudDraftSync(it, client::getCloudDrafts, client::saveCloudDraft) } - private val outgoingTextSync = warmup?.let { cache -> - OutgoingTextSync(cache, send = { row -> - sendRetainedMessage(client, row) - }, sendMedia = { row -> sendRetainedMedia(cache, client, row) }) - } private var rootForeground = lifecycle.state == Lifecycle.State.STARTED || lifecycle.state == Lifecycle.State.RESUMED - private var draftSyncAuthorized = false private var accountFlagsVersion = 0L private var listDetailVisible = false private val incomingShareState = MutableStateFlow(null) @@ -134,15 +119,26 @@ class RootComponent( incomingShareState.value = value } + fun accountSwitchDestination(): Config { + val active = stack.value.active + val page = + (active.instance as? Child.Settings)?.component?.pages?.value?.active?.configuration + return accountSwitchDestination(active.configuration, page) + } + fun setListDetailVisible(visible: Boolean) { listDetailVisible = visible } internal val proxySession: ProxySession = ClientProxySession(client) - fun openFromNotification(chatId: Long, messageId: Int = 0) { - if (stack.value.active.configuration.isAuthArea()) return - targetWarmup.select(chatId, messageId) + fun openFromNotification( + chatId: Long, + messageId: Int = 0, + ) { + if (stack.value.active.configuration.isAuthArea()) { + return + } AppLog.api("notify", "open chat=$chatId") pushRegistration?.onVisibleChat(chatId) if (bubbleChatId != null) { @@ -158,8 +154,13 @@ class RootComponent( } } - fun openChatForPlayback(chatId: Long, messageId: Int = 0) { - if (stack.value.active.configuration.isAuthArea()) return + fun openChatForPlayback( + chatId: Long, + messageId: Int = 0, + ) { + if (stack.value.active.configuration.isAuthArea()) { + return + } navigation.navigate { configurations -> chatSelectionStack( configurations, @@ -195,8 +196,11 @@ class RootComponent( onIncomingShareConsumed() } - private fun openChatFromList(peer: PeerId, forum: Boolean, messageId: Int = 0) { - targetWarmup.select(peer.value, messageId) + private fun openChatFromList( + peer: PeerId, + forum: Boolean, + messageId: Int = 0, + ) { val destination = Config.Dialog( chatId = peer.value, isForum = forum, @@ -260,11 +264,17 @@ class RootComponent( } } + private var restoringSignedOutStack = !startOnHome + val stack: Value> = childStack( source = navigation, + key = "root:${activeAccountUserId ?: "unauthenticated"}", serializer = Config.serializer(), - initialConfiguration = if (!startOnHome) Config.Auth - else bubbleChatId?.let { Config.Dialog(it) } ?: Config.Home, + initialConfiguration = if (!startOnHome) { + Config.Auth + } else { + bubbleChatId?.let { Config.Dialog(it) } ?: Config.Home + }, handleBackButton = true, childFactory = ::child, ) @@ -275,7 +285,9 @@ class RootComponent( fun openTelegramUri(uri: String): Boolean { val link = parseTelegramLink(uri) ?: return false - if (stack.value.active.configuration.isAuthArea()) return true + if (stack.value.active.configuration.isAuthArea()) { + return true + } scope.launch { openTelegramLink(link) } return true } @@ -284,127 +296,51 @@ class RootComponent( when (link) { is TelegramLink.Username -> { when (val resolved = client.resolveUsername(link.username)) { - is Outcome.Ok -> openFromNotification( - resolved.value.peerId.value, - link.messageId ?: 0, - ) + is Outcome.Ok -> { + openFromNotification( + resolved.value.peerId.value, + link.messageId ?: 0, + ) + } - is Outcome.Err -> AppLog.warn("t.me", "resolve failed") + is Outcome.Err -> { + AppLog.warn("t.me", "resolve failed") + } } } - is TelegramLink.PrivateChannel -> openFromNotification(link.chatId, link.messageId) - - is TelegramLink.Invite, is TelegramLink.Share -> AppLog.api("t.me", "link type ignored") - } - } - - private suspend fun commitDurableBatch(batch: DurableUpdateBatch): Boolean { - val cache = warmup ?: return true - if (expiringSession) return true - val cursor = batch.cursor?.let { - UpdateCursorEntity( - SessionMetadataStore.KEY_PRIMARY_CURSOR, it.pts, it.qts, it.date, it.seq - ) - } - val releasedSources = mutableListOf() - val committed = cache.applyDurableBatch( - batch.accountGeneration, batch.batchId, cursor, batch.channelCursors - ) { - val receipts = - batch.updates.filterIsInstance() - val media = cache.outgoingMediaSends() - .filter { send -> send.items.any { item -> receipts.any { it.randomId == item.randomId } } } - val mediaIds = media.flatMap { send -> send.items.map { it.randomId } }.toSet() - batch.updates.forEach { - if (it is MtprotoUpdate.OutgoingMessageAcknowledged && it.randomId in mediaIds) return@forEach - applyDurableUpdate( - cache, it, batch.accountGeneration, batch.batchId - ) - } - media.forEach { send -> - val ids = send.items.map { it.randomId }.toSet() - cache.applyOutgoingMediaResults( - send.batch.batchId, - receipts.filter { it.randomId in ids }.map { it.randomId to it.result }, - cache.outgoingAccountEpoch(), - batch.accountGeneration, - batch.batchId - ) - releasedSources += send.items.map { it.source } + is TelegramLink.PrivateChannel -> { + openFromNotification(link.chatId, link.messageId) } - } - if (committed && releasedSources.isNotEmpty()) { - try { - cache.outgoingMediaFiles?.release(releasedSources) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - AppLog.warn("outgoing", "retained media cleanup deferred") + + is TelegramLink.Invite, is TelegramLink.Share -> { + AppLog.api("t.me", "link type ignored") } } - return committed } - private suspend fun applyDurableUpdate( - cache: OfflineWarmup, - update: MtprotoUpdate, - accountGeneration: Long, - acceptedOrder: Long, - ) { - when (update) { - is MtprotoUpdate.NewMessage -> cache.applyIncomingMessage( - update.message, - accountGeneration, - acceptedOrder - ) - - is MtprotoUpdate.OutgoingMessageAcknowledged -> cache.applyOutgoingResult( - update.randomId, update.result, accountGeneration, acceptedOrder, - ) - - is MtprotoUpdate.ScheduledMessageChanged -> cache.applyScheduledMessage(update.message) - is MtprotoUpdate.ScheduledMessagesDeleted -> cache.deleteScheduledMessages( - update.chatId, update.messageIds - ) - - is MtprotoUpdate.DraftChanged -> cache.applyCloudDraft(update.draft) - is MtprotoUpdate.MessageEdited -> cache.applyMessageEdit(update.message) - is MtprotoUpdate.MessagesDeleted -> cache.deleteMessages( - update.chatId, update.messageIds - ) - - is MtprotoUpdate.MessageReactions -> cache.applyReactions( - update.chatId, - update.messageId, - update.reactionsJson - ) - - is MtprotoUpdate.ReadOutbox -> cache.applyOutboxRead(update.chatId, update.maxId) - is MtprotoUpdate.DiscussionInbox -> cache.applyDiscussionRead( - update.channelId, - update.topMessageId, - update.readMaxId - ) - - is MtprotoUpdate.ReadInbox -> cache.applyInboxRead( - update.chatId, update.maxId, update.stillUnread - ) - - is MtprotoUpdate.ChatsChanged -> cache.upsertChats(update.chats) - is MtprotoUpdate.UnreadMentionsDelta -> cache.addUnreadMentions( - update.chatId, update.delta - ) - - is MtprotoUpdate.UnreadReactionsDelta -> cache.addUnreadReactions( - update.chatId, update.delta - ) + private val durableUpdateSink = warmup?.let(::AccountDurableUpdateSink) - else -> Unit - } + private suspend fun commitDurableBatch(batch: DurableUpdateBatch): Boolean { + if (expiringSession) return false + return durableUpdateSink?.commit(batch) ?: false } init { + if (startOnHome && accountSwitchDestination != null && bubbleChatId == null) { + navigation.navigate { accountSwitchStack(accountSwitchDestination) } + } + if (restoringSignedOutStack && stack.value.items.any { !it.configuration.isAuthArea() }) { + navigation.replaceAll(Config.Auth) + } + if (stack.value.items.any { it.configuration == Config.AddAccount }) { + navigation.navigate { configurations -> + configurations.filterNot { it == Config.AddAccount }.ifEmpty { + listOf(if (startOnHome) Config.Home else Config.Auth) + } + } + } + restoringSignedOutStack = false if (startOnHome && stack.value.active.configuration is Config.Auth) { navigation.replaceAll(Config.Home) } @@ -414,15 +350,12 @@ class RootComponent( dialog?.chatId, dialog?.threadTopMsgId?.takeIf { it > 0 }) } lifecycle.doOnStart { - if (draftSyncAuthorized && !expiringSession) { - draftSync?.requestRefresh() - outgoingTextSync?.requestResume() - } + onAccountWorkResume() if (!stack.value.active.configuration.isAuthArea()) { scope.launch { sessionStore?.readAuthorizedUserId()?.value?.let { pushRegistration?.setAccountUserId( - it + it, ) } runCatching { pushRegistration?.reregister() } @@ -445,25 +378,32 @@ class RootComponent( try { when (update) { is MtprotoUpdate.SyncState -> { - if (!update.isSyncing && draftSyncAuthorized) { - draftSync?.requestRefresh() - outgoingTextSync?.requestResume() - } + if (!update.isSyncing) onAccountWorkResume() + } + + is MtprotoUpdate.FoldersChanged -> { + warmup?.replaceFolders(update.folders) } - is MtprotoUpdate.FoldersChanged -> warmup?.replaceFolders(update.folders) is MtprotoUpdate.ChatsChanged -> { warmup?.upsertChats(update.chats) sessionStore?.upsertPeersFromChats(update.chats) } - is MtprotoUpdate.PeerStatus -> sessionStore?.updatePeerStatus( - update.userId.value, update.status, update.statusAt - ) + is MtprotoUpdate.PeerStatus -> { + sessionStore?.updatePeerStatus( + update.userId.value, + update.status, + update.statusAt, + ) + } - is MtprotoUpdate.PeerEmojiStatus -> sessionStore?.updatePeerEmojiStatus( - update.userId.value, update.documentId - ) + is MtprotoUpdate.PeerEmojiStatus -> { + sessionStore?.updatePeerEmojiStatus( + update.userId.value, + update.documentId, + ) + } is MtprotoUpdate.ReadInbox -> { if (update.stillUnread <= 0) { @@ -473,22 +413,29 @@ class RootComponent( is MtprotoUpdate.ReadHistoryConfirmed -> { warmup?.markChatRead( - update.chatId, update.maxId, + update.chatId, + update.maxId, ) pushRegistration?.onChatRead(update.chatId.value) } - is MtprotoUpdate.UnreadMentions -> warmup?.applyUnreadMentions( - update.chatId, - update.stillUnread, - ) + is MtprotoUpdate.UnreadMentions -> { + warmup?.applyUnreadMentions( + update.chatId, + update.stillUnread, + ) + } - is MtprotoUpdate.UnreadReactions -> warmup?.applyUnreadReactions( - update.chatId, - update.stillUnread, - ) + is MtprotoUpdate.UnreadReactions -> { + warmup?.applyUnreadReactions( + update.chatId, + update.stillUnread, + ) + } - else -> Unit + else -> { + Unit + } } } catch (e: CancellationException) { throw e @@ -505,19 +452,14 @@ class RootComponent( } scope.launch { val bridged = client as? BridgedMtprotoClient ?: return@launch + if (sessionStore?.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0") return@launch val result = withContext(Dispatchers.IO) { bridged.isAuthorized() } + if (sessionStore?.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0") return@launch if (result is Outcome.Ok && result.value) { if (!expiringSession) { - warmup?.activateOutgoingAccount() - if (expiringSession) { - warmup?.deactivateOutgoingAccount(); return@launch - } - draftSyncAuthorized = true - draftSync?.start(scope) - outgoingTextSync?.start(scope) startLiveLocations() } - if (!expiringSession && stack.value.active.configuration.isAuthArea()) { + if (!expiringSession && stack.value.active.configuration.isAuthArea() && stack.value.active.configuration != Config.AddAccount) { navigation.replaceAll(Config.Home) pendingIncomingShare?.let(::openIncomingShare) } @@ -557,28 +499,37 @@ class RootComponent( when (val profile = client.getProfile(PeerId(0))) { is Outcome.Ok -> { if (expiringSession || version != accountFlagsVersion) return + if (activeAccountUserId != null && profile.value.id.value != activeAccountUserId) { + AppLog.warn("account", "profile identity does not match active account") + return + } accountState.isPremium = profile.value.isPremium DownloadSettings.setPremium(profile.value.isPremium) sessionStore?.savePremium(profile.value.isPremium) sessionStore?.upsertProfile(profile.value) - if (sessionStore?.readAuthorizedUserId() == null) { - sessionStore?.saveAuthorized(AuthSession(profile.value.id, 0)) + if (expiringSession || version != accountFlagsVersion) return + recoveredProfileSession( + profile.value.id, + activeAccountUserId, + activeAccountDcId, + )?.let { + sessionStore?.recoverAuthorizedIfAbsent(it) } } - is Outcome.Err -> Unit + is Outcome.Err -> { + Unit + } } } private suspend fun expireSession() { if (expiringSession) return expiringSession = true - draftSyncAuthorized = false - warmup?.deactivateOutgoingAccount() - draftSync?.stop() - outgoingTextSync?.stop() + clearDialogMemory(client) + stopAudioMessagePlayback() + onAccountWorkStop() stopLiveLocations() - accountFlagsVersion++ accountState.isPremium = false // Destroy account screens and their collectors before clearing their cache. MediaPlaybackHolder.peek()?.stop() @@ -587,211 +538,298 @@ class RootComponent( warmup?.clearAccountCache() sessionStore?.clearSession() mediaRepository?.clearCache() - ImageCache.clear() + ImageCache.clear(mediaRepository?.cacheRoot) } } - private fun child(config: Config, context: ComponentContext): Child = when (config) { - is Config.Recipients -> Child.Recipients( - RecipientPickerComponent(context, config.request, client, warmup) { - navigation.pop() - }, - ) + private fun child( + config: Config, + context: ComponentContext, + ): Child = if (restoringSignedOutStack && !config.isAuthArea()) { + // Decompose restores children before init can clear the signed-out stack. + Child.Proxy + } else if (config == Config.AddAccount && pendingAdditionalAuthorization == null) { + // Restored add-account screens have no pending client; init removes the placeholder. + Child.Proxy + } else { + when (config) { + is Config.Recipients -> { + Child.Recipients( + RecipientPickerComponent(context, config.request, client, warmup) { + navigation.pop() + }, + ) + } - Config.Auth -> Child.Auth( - AuthComponent( - componentContext = context, - storeFactory = storeFactory, - client = client, - onAuthorized = { session -> - expiringSession = false - scope.launch { - sessionStore?.saveAuthorized(session) - if (expiringSession) return@launch - warmup?.activateOutgoingAccount() - if (expiringSession) { - warmup?.deactivateOutgoingAccount(); return@launch + Config.Auth -> { + Child.Auth( + AuthComponent(context, storeFactory, client) { session -> + expiringSession = false + scope.launch { + onAccountAuthorized(session) + sessionStore?.saveAuthorized(session) + if (expiringSession) return@launch + activeAccountUserId = session.userId.value + activeAccountDcId = session.dcId + try { + loadAccountMenuItems?.invoke()?.let { items -> + accountMenuItems = items + accountUserIds = items.map { it.userId } + accountNeedsSignInIds = items.filter { it.needsSignIn } + .mapTo(mutableSetOf()) { it.userId } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("account", "account information unavailable") + } + if (expiringSession) return@launch + navigation.replaceAll(Config.Home) + pendingIncomingShare?.let(::openIncomingShare) + startLiveLocations() + pushRegistration?.setAccountUserId(session.userId.value) + refreshAccountFlags() + runCatching { pushRegistration?.reregister() } } - draftSyncAuthorized = true - draftSync?.start(scope) - outgoingTextSync?.start(scope) - startLiveLocations() - pushRegistration?.setAccountUserId(session.userId.value) - refreshAccountFlags() - runCatching { pushRegistration?.reregister() } - } - navigation.replaceAll(Config.Home) - pendingIncomingShare?.let(::openIncomingShare) - }, - ), - ) + }, + ) + } - Config.Proxy -> Child.Proxy - - Config.Home -> Child.Home( - HomeComponent( - componentContext = context, - storeFactory = storeFactory, - client = client, - warmup = warmup, - sessionStore = sessionStore, - notificationLocal = notificationLocal, - mediaRepository = mediaRepository, - onOpenChat = ::openChatFromList, - onOpenProfile = ::openProfile, - onOpenSettings = { openSettings() }, - onOpenProxy = { openSettings(openProxy = true) }, - onOpenFolders = { openSettings(openFolders = true) }, - ), - ) + Config.AddAccount -> { + Child.Auth( + AuthComponent( + context, + storeFactory, + checkNotNull(pendingAdditionalAuthorization).client, + ) { session -> + val pending = pendingAdditionalAuthorization ?: return@AuthComponent + scope.launch { + onAdditionalAccountAuthorized(session, pending) + pendingAdditionalAuthorization = null + navigation.replaceAll(Config.Home) + } + }, + ) + } - is Config.Dialog -> { - targetWarmup.promote(config.chatId) - val liveDialogEpoch = warmup?.outgoingAccountEpoch() - val component = DialogComponent( - componentContext = context, - storeFactory = storeFactory, - client = client, - warmup = warmup, - sessionStore = sessionStore, - mediaRepository = mediaRepository, - chatId = PeerId(config.chatId), - jumpToMessageId = config.jumpToMessageId, - threadTopMsgId = config.threadTopMsgId, - isForum = config.isForum, - markup = NativeMarkupParser(), - isPremium = { accountState.isPremium }, - onBack = ::onBack, - onOpenScheduledQueue = { - navigation.navigate { uniqueStack(it, Config.ScheduledQueue(config.chatId)) } - }, - onResumeLiveLocation = liveLocations?.takeIf { liveDialogEpoch != null } - ?.let { runtime -> { id -> runtime.resume(id, liveDialogEpoch) } }, - onStopLiveLocation = liveLocations?.takeIf { liveDialogEpoch != null } - ?.let { runtime -> { id -> runtime.requestStop(id, liveDialogEpoch) } }, - liveLocationErrors = liveLocations?.errors ?: kotlinx.coroutines.flow.flowOf( - emptyMap() - ), - liveLocationFailure = liveLocations?.failure - ?: kotlinx.coroutines.flow.flowOf(null), - showBackButton = config.chatId != bubbleChatId || config.threadTopMsgId != 0, - onOpenProfile = ::openProfile, - onOpenChat = { peer, jump, threadTop -> - navigation.navigate { configurations -> - uniqueStack( - configurations, - Config.Dialog( - peer.value, - jump, - threadTop, - isForum = if (peer.value == config.chatId) config.isForum else false, + Config.Proxy -> { + Child.Proxy + } + + Config.Home -> { + Child.Home( + HomeComponent( + componentContext = context, + storeFactory = storeFactory, + client = client, + warmup = warmup, + sessionStore = sessionStore, + notificationLocal = notificationLocal, + mediaRepository = mediaRepository, + onOpenChat = ::openChatFromList, + onOpenProfile = ::openProfile, + onOpenSettings = { openSettings() }, + onOpenProxy = { openSettings(openProxy = true) }, + onOpenFolders = { openSettings(openFolders = true) }, + accountMenuItems = accountMenuItems, + onSwitchAccount = { userId -> scope.launch { onSwitchAccount(userId) } }, + onAddAccount = ::beginAddAccount, + ), + ) + } + + is Config.Dialog -> { + val liveDialogEpoch = warmup?.outgoingAccountEpoch() + val component = DialogComponent( + componentContext = context, + storeFactory = storeFactory, + client = client, + warmup = warmup, + sessionStore = sessionStore, + mediaRepository = mediaRepository, + chatId = PeerId(config.chatId), + jumpToMessageId = config.jumpToMessageId, + threadTopMsgId = config.threadTopMsgId, + isForum = config.isForum, + markup = NativeMarkupParser(), + isPremium = { accountState.isPremium }, + onBack = ::onBack, + onOpenScheduledQueue = { + navigation.navigate { + uniqueStack( + it, + Config.ScheduledQueue(config.chatId), + ) + } + }, + onResumeLiveLocation = liveLocations?.takeIf { liveDialogEpoch != null } + ?.let { runtime -> + { id -> + runtime.resume( + id, liveDialogEpoch + ) + } + }, + onStopLiveLocation = liveLocations?.takeIf { liveDialogEpoch != null } + ?.let { runtime -> + { id -> + runtime.requestStop( + id, liveDialogEpoch + ) + } + }, + liveLocationErrors = liveLocations?.errors ?: kotlinx.coroutines.flow.flowOf( + emptyMap(), + ), + liveLocationFailure = liveLocations?.failure ?: kotlinx.coroutines.flow.flowOf( + null, + ), + showBackButton = config.chatId != bubbleChatId || config.threadTopMsgId != 0, + onOpenProfile = ::openProfile, + onOpenChat = { peer, jump, threadTop -> + navigation.navigate { configurations -> + uniqueStack( + configurations, + Config.Dialog( + peer.value, + jump, + threadTop, + isForum = if (peer.value == config.chatId) config.isForum else false, + ), + ) + } + }, + onRequestForward = { messages -> + openRecipientPicker( + RecipientRequest( + fromChatId = config.chatId, + messageIds = messages.map { it.id.id }, + requiresPhotos = messages.any { it.requiresForwardPhotoRight() }, ), ) - } - }, - onRequestForward = { messages -> - openRecipientPicker( - RecipientRequest( - fromChatId = config.chatId, - messageIds = messages.map { it.id.id }, - requiresPhotos = messages.any { it.requiresForwardPhotoRight() }, - ) - ) - }, - ) - config.incomingShare?.let { share -> - if (share.text.isNotBlank()) component.onDraftChanged(share.text) - val items = share.attachments.map(IncomingShareAttachment::toUploadItem) - if (items.isNotEmpty()) component.onAttachMedia(items) + }, + ) + config.incomingShare?.let { share -> + if (share.text.isNotBlank()) component.onDraftChanged(share.text) + val items = share.attachments.map(IncomingShareAttachment::toUploadItem) + if (items.isNotEmpty()) component.onAttachMedia(items) + } + Child.Dialog(component) } - Child.Dialog(component) - } - - is Config.ScheduledQueue -> Child.ScheduledQueue( - ScheduledQueueComponent( - context, - storeFactory, - client, - warmup, - PeerId(config.chatId), - ::onBack, - mediaRepository, - ), - ) - is Config.Profile -> Child.Profile( - ProfileComponent( - componentContext = context, - storeFactory = storeFactory, - client = client, - sessionStore = sessionStore, - mediaRepository = mediaRepository, - peerId = PeerId(config.peerId), - onBack = { navigation.pop() }, - onOpenChat = ::openChatFromProfile, - onOpenProfile = ::openProfile, - ), - ) + is Config.ScheduledQueue -> { + Child.ScheduledQueue( + ScheduledQueueComponent( + context, + storeFactory, + client, + warmup, + PeerId(config.chatId), + ::onBack, + mediaRepository, + ), + ) + } - is Config.Settings -> Child.Settings( - SettingsComponent( - componentContext = context, - storeFactory = storeFactory, - client = client, - sessionStore = sessionStore, - warmup = warmup, - mediaRepository = mediaRepository, - appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", - buildStamp = "${BuildConfig.BUILD_TYPE} · ${BuildConfig.GIT_COMMIT}", - onBack = { navigation.pop() }, - onOpenProfile = ::openProfile, - onBeforeLogout = { - expiringSession = true - draftSyncAuthorized = false - warmup?.deactivateOutgoingAccount() - draftSync?.stop() - outgoingTextSync?.stop() - stopLiveLocations() - }, - onLoggedOut = { - expiringSession = true - draftSyncAuthorized = false - scope.launch { - draftSync?.stop() - outgoingTextSync?.stop() - stopLiveLocations() - MediaPlaybackHolder.peek()?.stop() - navigation.replaceAll(Config.Auth) - } - }, - pushRegistration = pushRegistration, - debugNotifications = BuildConfig.DEBUG, - notificationLocal = notificationLocal, - openFolders = config.openFolders, - openProxy = config.openProxy, - openNotifications = config.openNotifications, - appUpdate = appUpdate, - updatesEnabled = AppUpdate.inAppUpdatesEnabled(BuildConfig.BUILD_TYPE), - ), - folders = FoldersComponent( - componentContext = context, - storeFactory = storeFactory, - client = client, - warmup = warmup, - ), - ) + is Config.Profile -> { + Child.Profile( + ProfileComponent( + componentContext = context, + storeFactory = storeFactory, + client = client, + sessionStore = sessionStore, + mediaRepository = mediaRepository, + peerId = PeerId(config.peerId), + onBack = { navigation.pop() }, + onOpenChat = ::openChatFromProfile, + onOpenProfile = ::openProfile, + ), + ) + } + + is Config.Settings -> { + Child.Settings( + SettingsComponent( + componentContext = context, + storeFactory = storeFactory, + client = client, + sessionStore = sessionStore, + warmup = warmup, + mediaRepository = mediaRepository, + appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + buildStamp = "${BuildConfig.BUILD_TYPE} · ${BuildConfig.GIT_COMMIT}", + onBack = { navigation.pop() }, + onOpenProfile = ::openProfile, + accountUserIds = accountUserIds, + activeAccountUserId = activeAccountUserId, + accountNeedsSignInIds = accountNeedsSignInIds, + loadAccountDetails = loadAccountDetails, + onRemoveAccount = onRemoveAccount, + onAddAccount = ::beginAddAccount, + onSwitchAccount = onSwitchAccount, + onBeforeLogout = { + expiringSession = true + clearDialogMemory(client) + stopAudioMessagePlayback() + onAccountWorkStop() + stopLiveLocations() + }, + onLoggedOut = { + expiringSession = true + scope.launch { + onAccountLoggedOut() + stopLiveLocations() + MediaPlaybackHolder.peek()?.stop() + navigation.replaceAll(Config.Auth) + } + }, + pushRegistration = pushRegistration, + debugNotifications = BuildConfig.DEBUG, + notificationLocal = notificationLocal, + openFolders = config.openFolders, + openProxy = config.openProxy, + openNotifications = config.openNotifications, + appUpdate = appUpdate, + updatesEnabled = AppUpdate.inAppUpdatesEnabled(BuildConfig.BUILD_TYPE), + ), + folders = FoldersComponent( + componentContext = context, + storeFactory = storeFactory, + client = client, + warmup = warmup, + ), + ) + } + } } sealed class Child { - class Recipients(val component: RecipientPickerComponent) : Child() - class Auth(val component: AuthComponent) : Child() + class Recipients( + val component: RecipientPickerComponent, + ) : Child() + + class Auth( + val component: AuthComponent, + ) : Child() + object Proxy : Child() - class Home(val component: HomeComponent) : Child() - class ScheduledQueue(val component: ScheduledQueueComponent) : - Child() - class Dialog(val component: DialogComponent) : Child() - class Profile(val component: ProfileComponent) : Child() + class Home( + val component: HomeComponent, + ) : Child() + + class ScheduledQueue( + val component: ScheduledQueueComponent, + ) : Child() + + class Dialog( + val component: DialogComponent, + ) : Child() + + class Profile( + val component: ProfileComponent, + ) : Child() + class Settings( val component: SettingsComponent, val folders: FoldersComponent, @@ -801,11 +839,16 @@ class RootComponent( @Serializable sealed interface Config { @Serializable - data class Recipients(val request: RecipientRequest) : Config + data class Recipients( + val request: RecipientRequest, + ) : Config @Serializable data object Auth : Config + @Serializable + data object AddAccount : Config + @Serializable data object Proxy : Config @@ -823,10 +866,14 @@ class RootComponent( ) : Config @Serializable - data class ScheduledQueue(val chatId: Long) : Config + data class ScheduledQueue( + val chatId: Long, + ) : Config @Serializable - data class Profile(val peerId: Long) : Config + data class Profile( + val peerId: Long, + ) : Config @Serializable data class Settings( @@ -837,8 +884,19 @@ class RootComponent( } } +internal fun recoveredProfileSession( + profileUserId: PeerId, + accountUserId: Long?, + accountDcId: Int?, +): AuthSession? = + if (profileUserId.value > 0 && profileUserId.value == accountUserId && accountDcId != null && accountDcId > 0) { + AuthSession(profileUserId, accountDcId) + } else { + null + } + internal fun RootComponent.Config.isAuthArea(): Boolean = - this is RootComponent.Config.Auth || this is RootComponent.Config.Proxy + this is RootComponent.Config.Auth || this is RootComponent.Config.AddAccount || this is RootComponent.Config.Proxy internal fun RootComponent.Child.isSignedOut(): Boolean = this is RootComponent.Child.Auth || this is RootComponent.Child.Proxy @@ -848,6 +906,35 @@ internal fun requiresSessionReset(result: Outcome): Boolean = when (res is Outcome.Err -> result.telegramError.requiresReauth } +internal fun accountSwitchDestination( + configuration: RootComponent.Config, + settingsPage: SettingsPage?, +): RootComponent.Config = when (configuration) { + is RootComponent.Config.Settings -> { + when (settingsPage) { + SettingsPage.Proxy -> RootComponent.Config.Settings(openProxy = true) + SettingsPage.Folders -> RootComponent.Config.Settings(openFolders = true) + SettingsPage.Notifications -> RootComponent.Config.Settings(openNotifications = true) + else -> RootComponent.Config.Settings() + } + } + + RootComponent.Config.Proxy -> { + RootComponent.Config.Proxy + } + + else -> { + RootComponent.Config.Home + } +} + +internal fun accountSwitchStack(destination: RootComponent.Config): List = + if (destination == RootComponent.Config.Home) { + listOf(destination) + } else { + listOf(RootComponent.Config.Home, destination) + } + internal fun chatSelectionStack( configurations: List, destination: RootComponent.Config.Dialog, @@ -866,7 +953,10 @@ internal fun chatSelectionStack( * whatever was open above it. Decompose rejects a stack holding two equal configurations, * which happens whenever a screen reopens a chat or profile that is still below it. */ -internal fun uniqueStack(configurations: List, destination: C): List { +internal fun uniqueStack( + configurations: List, + destination: C, +): List { val existing = configurations.indexOf(destination) return if (existing >= 0) configurations.take(existing + 1) else configurations + destination } @@ -884,6 +974,9 @@ class HomeComponent( onOpenSettings: () -> Unit, onOpenProxy: () -> Unit = {}, onOpenFolders: () -> Unit, + accountMenuItems: List = emptyList(), + onSwitchAccount: (Long) -> Unit = {}, + onAddAccount: () -> Unit = {}, ) : ComponentContext by componentContext { val chats = ChatsComponent( componentContext = componentContext, @@ -899,6 +992,9 @@ class HomeComponent( onOpenSettings = onOpenSettings, onOpenProxy = onOpenProxy, onOpenFolders = onOpenFolders, + accountMenuItems = accountMenuItems, + onSwitchAccount = onSwitchAccount, + onAddAccount = onAddAccount, ) val folders = FoldersComponent(componentContext, storeFactory, client, warmup) } diff --git a/app/src/main/java/org/monogram/root/RootContent.kt b/app/src/main/java/org/monogram/root/RootContent.kt index e5acf65bc..9aa10e71f 100644 --- a/app/src/main/java/org/monogram/root/RootContent.kt +++ b/app/src/main/java/org/monogram/root/RootContent.kt @@ -114,7 +114,10 @@ import kotlin.math.roundToInt @Composable @OptIn(ExperimentalDecomposeApi::class, FaultyDecomposeApi::class) -fun RootContent(component: RootComponent, modifier: Modifier = Modifier) { +fun RootContent( + component: RootComponent, + modifier: Modifier = Modifier, +) { val stack by component.stack.subscribeAsState() AudioMessagePlaybackScope( enabled = !stack.active.instance.isSignedOut(), @@ -124,10 +127,11 @@ fun RootContent(component: RootComponent, modifier: Modifier = Modifier) { @Composable @OptIn(ExperimentalDecomposeApi::class, FaultyDecomposeApi::class) private fun RootPlaybackContent( - component: RootComponent, modifier: Modifier, togglePlayback: () -> Unit + component: RootComponent, + modifier: Modifier, + togglePlayback: () -> Unit, ) { RecompositionProbe("AppRoot") - SideEffect { component.noteFirstFrame() } val currentStack by component.stack.subscribeAsState() val playbackContext = LocalContext.current val playbackSession = remember(playbackContext) { MediaPlaybackHolder.session(playbackContext) } @@ -155,7 +159,9 @@ private fun RootPlaybackContent( } val selectedChatId by remember { derivedStateOf { - (currentStack.items.lastOrNull { it.configuration is RootComponent.Config.Dialog }?.configuration as? RootComponent.Config.Dialog)?.chatId + (currentStack.items.lastOrNull { + it.configuration is RootComponent.Config.Dialog + }?.configuration as? RootComponent.Config.Dialog)?.chatId } } val homeState = rememberSaveableStateHolder() @@ -181,7 +187,9 @@ private fun RootPlaybackContent( val layoutDirection = LocalLayoutDirection.current val direction = if (layoutDirection == LayoutDirection.Ltr) 1f else -1f val motion = mediaViewerMotionEnabled() - val gestureDispatcher = remember { com.arkivanov.essenty.backhandler.BackDispatcher() } + val gestureDispatcher = remember { + com.arkivanov.essenty.backhandler.BackDispatcher() + } val backHandler = remember(component, gestureDispatcher) { GestureBackHandler(component.backHandler, gestureDispatcher) } @@ -201,7 +209,8 @@ private fun RootPlaybackContent( Direction.EXIT_BACK -> width * factor } alpha = alphaProgress - }) + }, + ) } predictiveBackAnimation( backHandler = backHandler, @@ -267,15 +276,23 @@ private fun RootPlaybackContent( .coerceAtLeast(AppearanceSettings.MIN_LIST_PANE_WIDTH) var dragWidthDp by remember { mutableIntStateOf(0) } val listWidthDp = when { - dragWidthDp > 0 -> dragWidthDp.coerceIn( - AppearanceSettings.MIN_LIST_PANE_WIDTH, maxListWidthDp - ) + dragWidthDp > 0 -> { + dragWidthDp.coerceIn( + AppearanceSettings.MIN_LIST_PANE_WIDTH, + maxListWidthDp, + ) + } - appearance.listPaneWidth > 0 -> appearance.listPaneWidth.coerceIn( - AppearanceSettings.MIN_LIST_PANE_WIDTH, maxListWidthDp - ) + appearance.listPaneWidth > 0 -> { + appearance.listPaneWidth.coerceIn( + AppearanceSettings.MIN_LIST_PANE_WIDTH, + maxListWidthDp, + ) + } - else -> defaultListWidthDp.coerceAtMost(maxListWidthDp) + else -> { + defaultListWidthDp.coerceAtMost(maxListWidthDp) + } } val liveListWidthDp by rememberUpdatedState(listWidthDp) SideEffect { component.setListDetailVisible(expanded) } @@ -290,18 +307,17 @@ private fun RootPlaybackContent( val dialogMediaHeld = rememberDelayedHold(dialogCovered) Layout( content = { - if (expanded) { Box( Modifier .then( - if (tabletopListHeightDp != null) Modifier - .fillMaxWidth() - .height(tabletopListHeightDp.dp) else Modifier - .width( - listWidthDp.dp - ) - .fillMaxHeight() + if (tabletopListHeightDp != null) { + Modifier.fillMaxWidth().height(tabletopListHeightDp.dp) + } else { + Modifier.width( + listWidthDp.dp, + ).fillMaxHeight() + }, ) .clip(MaterialTheme.shapes.extraLarge) .background(MaterialTheme.colorScheme.surfaceContainerLow), @@ -311,14 +327,14 @@ private fun RootPlaybackContent( Box( modifier = Modifier .then( - if (tabletopListHeightDp != null) Modifier - .fillMaxWidth() - .height( - ((fold.hinge.height / density.density).roundToInt() - .coerceAtLeast(14)).dp - ) else Modifier - .width((foldHingeGapDp ?: 14).dp) - .fillMaxHeight() + if (tabletopListHeightDp != null) { + Modifier.fillMaxWidth().height( + ((fold.hinge.height / density.density).roundToInt() + .coerceAtLeast(14)).dp, + ) + } else { + Modifier.width((foldHingeGapDp ?: 14).dp).fillMaxHeight() + }, ) .pointerInput(maxListWidthDp) { detectHorizontalDragGestures( @@ -326,9 +342,11 @@ private fun RootPlaybackContent( onDragEnd = { // Read the dragged state, not the captured composition value: // the pointerInput block is not restarted while the drag runs. - if (dragWidthDp > 0) AppearanceSettings.setListPaneWidth( - dragWidthDp - ) + if (dragWidthDp > 0) { + AppearanceSettings.setListPaneWidth( + dragWidthDp, + ) + } dragWidthDp = 0 }, onHorizontalDrag = { change, delta -> @@ -336,7 +354,7 @@ private fun RootPlaybackContent( dragWidthDp = (dragWidthDp + (delta / density.density).roundToInt()).coerceIn( AppearanceSettings.MIN_LIST_PANE_WIDTH, - maxListWidthDp + maxListWidthDp, ) }, ) @@ -357,8 +375,11 @@ private fun RootPlaybackContent( .offset(x = nudge.value.dp) .clip(RoundedCornerShape(percent = 50)) .background( - if (dragging) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outlineVariant, + if (dragging) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, ), ) } @@ -374,7 +395,7 @@ private fun RootPlaybackContent( topEnd = 32.dp, bottomStart = 32.dp, bottomEnd = 32.dp, - ) + ), ) } else { Modifier @@ -399,77 +420,100 @@ private fun RootPlaybackContent( modifier = Modifier .fillMaxSize() .chatBackGesture( - gestureDispatcher, layoutDirection, + gestureDispatcher, + layoutDirection, prioritizeChildren = { true }, ) { !expanded && when (component.stack.value.active.instance) { is RootComponent.Child.Dialog, is RootComponent.Child.Profile, is RootComponent.Child.ScheduledQueue, is RootComponent.Child.Settings, is RootComponent.Child.Proxy -> true - else -> false } }, animation = animation, ) { child -> when (val instance = child.instance) { - is RootComponent.Child.Recipients -> if (expanded && recipient != null) { - val sourceDialog = dialog - if (sourceDialog == null) { - EmptyDetailContent() - } else { - Box( - Modifier - .fillMaxSize() - .clearAndSetSemantics { }) { - key(sourceDialog) { dialogContent(sourceDialog) } + is RootComponent.Child.Recipients -> { + if (expanded && recipient != null) { + val sourceDialog = dialog + if (sourceDialog == null) { + EmptyDetailContent() + } else { Box( Modifier .fillMaxSize() - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() } + .clearAndSetSemantics { }, + ) { + key(sourceDialog) { dialogContent(sourceDialog) } + Box( + Modifier + .fillMaxSize() + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent( + PointerEventPass.Initial, + ).changes.forEach { it.consume() } + } } - } - }) + }, + ) + } } + } else { + Box(Modifier.fillMaxSize()) } - } else Box(Modifier.fillMaxSize()) + } - is RootComponent.Child.Auth -> AuthContent( - component = instance.component, - onOpenProxy = component::openProxy, - ) + is RootComponent.Child.Auth -> { + AuthContent( + component = instance.component, + onOpenProxy = component::openProxy, + ) + } - is RootComponent.Child.Proxy -> ProxySettingsContent( - session = component.proxySession, - onBack = component::onBack, - showAppBar = true, - ) + is RootComponent.Child.Proxy -> { + ProxySettingsContent( + session = component.proxySession, + onBack = component::onBack, + showAppBar = true, + ) + } - is RootComponent.Child.Home -> if (expanded) { - EmptyDetailContent() - } else if (compactHome) { - Box(Modifier.fillMaxSize()) - } else { - homeContent(instance.component, selectedChatId, true) + is RootComponent.Child.Home -> { + if (expanded) { + EmptyDetailContent() + } else if (compactHome) { + Box(Modifier.fillMaxSize()) + } else { + homeContent(instance.component, selectedChatId, true) + } } - is RootComponent.Child.Dialog -> if (dialogCovered) { - Box(Modifier.fillMaxSize()) - } else { - key(instance.component) { dialogContent(instance.component) } + is RootComponent.Child.Dialog -> { + if (dialogCovered) { + Box(Modifier.fillMaxSize()) + } else { + key(instance.component) { dialogContent(instance.component) } + } } - is RootComponent.Child.ScheduledQueue -> ScheduledQueueContent( - instance.component - ) + is RootComponent.Child.ScheduledQueue -> { + ScheduledQueueContent( + instance.component, + ) + } - is RootComponent.Child.Profile -> ProfileContent(instance.component) - is RootComponent.Child.Settings -> SettingsContent( - component = instance.component, - gestureDispatcher = gestureDispatcher, - folders = rememberSettingsFolderHost(instance.folders), - ) + is RootComponent.Child.Profile -> { + ProfileContent(instance.component) + } + + is RootComponent.Child.Settings -> { + SettingsContent( + component = instance.component, + gestureDispatcher = gestureDispatcher, + folders = rememberSettingsFolderHost(instance.folders), + ) + } } } if (compactHome) { @@ -517,12 +561,17 @@ private fun RootPlaybackContent( val listHeight = if (tabletopListHeightDp != null) (tabletopListHeightDp * density.density).roundToInt() else constraints.maxHeight val list = measurables[0].measure(Constraints(0, listMax, 0, listHeight)) - val dividerMax = - if (tabletopListHeightDp != null) constraints.maxWidth else (foldHingeGapDp?.let { (it * density.density).roundToInt() } + val dividerMax = if (tabletopListHeightDp != null) { + constraints.maxWidth + } else { + (foldHingeGapDp?.let { (it * density.density).roundToInt() } ?: (14 * density.density).roundToInt()) - val dividerHeight = - if (tabletopListHeightDp != null) (fold.hinge.height / density.density).roundToInt() - .coerceAtLeast(14) else constraints.maxHeight + } + val dividerHeight = if (tabletopListHeightDp != null) { + (fold.hinge.height / density.density).roundToInt().coerceAtLeast(14) + } else { + constraints.maxHeight + } val divider = measurables[1].measure(Constraints(0, dividerMax, 0, dividerHeight)) val detailTop = @@ -535,7 +584,7 @@ private fun RootPlaybackContent( (constraints.maxWidth - detailLeft).coerceAtLeast(0), 0, (constraints.maxHeight - detailTop).coerceAtLeast(0), - ) + ), ) if (tabletopListHeightDp != null) { val y = detailTop @@ -578,7 +627,8 @@ private fun RootPlaybackContent( } if (showMessagePlayback) { org.monogram.core.ui.media.FloatingVideoNote( - session = playbackSession, onOpenMessage = openPlaybackMessage, + session = playbackSession, + onOpenMessage = openPlaybackMessage, modifier = Modifier .fillMaxSize() .padding(bottom = 56.dp), @@ -595,7 +645,8 @@ private fun RootPlaybackContent( @Composable private fun EmptyDetailContent() { Surface( - color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxSize() + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxSize(), ) { Column( modifier = Modifier @@ -679,13 +730,13 @@ private fun PlaybackBar( visible = visible && session.showsMiniPlayer(pictureInPicture), enter = slideInVertically(MediaMotion.spatial(motion)) { it } + fadeIn( MediaMotion.effects( - motion - ) + motion, + ), ), exit = slideOutVertically(MediaMotion.spatial(motion)) { it } + fadeOut( MediaMotion.quick( - motion - ) + motion, + ), ), modifier = modifier, ) { diff --git a/app/src/main/java/org/monogram/sponsor/SponsorSyncManager.kt b/app/src/main/java/org/monogram/sponsor/SponsorSyncManager.kt index 39ab58e1b..db9aa8420 100644 --- a/app/src/main/java/org/monogram/sponsor/SponsorSyncManager.kt +++ b/app/src/main/java/org/monogram/sponsor/SponsorSyncManager.kt @@ -1,6 +1,5 @@ package org.monogram.sponsor -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -23,6 +22,7 @@ import org.monogram.core.models.PeerId import org.monogram.core.models.SponsorState import org.monogram.network.bridge.MtprotoClient import org.monogram.network.bridge.MtprotoUpdate +import java.util.concurrent.atomic.AtomicBoolean private const val SPONSOR_CHANNEL_ID = -1003640797855L private const val SPONSOR_CHANNEL_USERNAME = "ahhfjfbdnejjfbfjdjdj" @@ -222,7 +222,9 @@ class SponsorSyncManager( parsedIds = retryParsedIds AppLog.api(LOG_TAG, "retry parsed=${parsedIds.size}") } else { - AppLog.warn(LOG_TAG, "retry parsed=${retryParsedIds.size} no improvement") + AppLog.warn( + LOG_TAG, "retry parsed=${retryParsedIds.size} no improvement" + ) } } @@ -264,8 +266,7 @@ class SponsorSyncManager( AppLog.api( LOG_TAG, - "done reason=$reason messages=${messages.size} parsed=${parsedIds.size} " + - "ids=${actualIds.size} added=${(actualIds - oldIds).size}", + "done reason=$reason messages=${messages.size} parsed=${parsedIds.size} " + "ids=${actualIds.size} added=${(actualIds - oldIds).size}", ) return SyncOutcome.SUCCESS } catch (e: CancellationException) { @@ -280,6 +281,7 @@ class SponsorSyncManager( } private suspend fun canSync(): Boolean { + if (sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0") return false if (sessionStore.isAuthorized()) return true return when (val result = client.isLocallyAuthorized()) { is Outcome.Ok -> result.value @@ -287,16 +289,13 @@ class SponsorSyncManager( } } - private suspend fun onTestServer(): Boolean = - when (val result = client.usesTestDc()) { - is Outcome.Ok -> result.value - is Outcome.Err -> false - } + private suspend fun onTestServer(): Boolean = when (val result = client.usesTestDc()) { + is Outcome.Ok -> result.value + is Outcome.Err -> false + } private fun isWeakResult(parsedIds: Set, oldIds: Set): Boolean = - parsedIds.isNotEmpty() && - oldIds.size >= WEAK_RESULT_MIN_OLD_IDS && - parsedIds.size < oldIds.size / 2 + parsedIds.isNotEmpty() && oldIds.size >= WEAK_RESULT_MIN_OLD_IDS && parsedIds.size < oldIds.size / 2 private suspend fun resolveSponsorChatId(): Long { val resolved = when (val outcome = client.resolveUsername(SPONSOR_CHANNEL_USERNAME)) { @@ -315,20 +314,19 @@ class SponsorSyncManager( var fromMessageId = 0 repeat(HISTORY_BATCHES_LIMIT) { - val batch = when ( - val outcome = client.getHistoryPage( - chatId = PeerId(chatId), - limit = HISTORY_LIMIT, - offsetId = fromMessageId, - ) - ) { + val batch = when (val outcome = client.getHistoryPage( + chatId = PeerId(chatId), + limit = HISTORY_LIMIT, + offsetId = fromMessageId, + )) { is Outcome.Ok -> outcome.value is Outcome.Err -> return HistoryLoadResult.Failure(outcome) } if (batch.isEmpty()) return HistoryLoadResult.Success(result) - val oldestInBatch = batch.minOfOrNull { it.id.id } ?: return HistoryLoadResult.Success(result) + val oldestInBatch = + batch.minOfOrNull { it.id.id } ?: return HistoryLoadResult.Success(result) batch.forEach { message -> if (seenIds.add(message.id.id)) result.add(message) } @@ -345,21 +343,19 @@ class SponsorSyncManager( private fun parseSponsorIds(messages: List): Set { var invalidTokens = 0 - val ids = messages.asSequence() - .mapNotNull { message -> message.text?.takeIf { it.isNotBlank() } } - .flatMap { text -> text.splitToSequence(",") } - .mapNotNull { token -> - val value = token.trim() - if (value.isEmpty()) return@mapNotNull null - val id = value.toLongOrNull() - if (id == null || id <= 0L) { - invalidTokens++ - null - } else { - id - } - } - .toSet() + val ids = + messages.asSequence().mapNotNull { message -> message.text?.takeIf { it.isNotBlank() } } + .flatMap { text -> text.splitToSequence(",") }.mapNotNull { token -> + val value = token.trim() + if (value.isEmpty()) return@mapNotNull null + val id = value.toLongOrNull() + if (id == null || id <= 0L) { + invalidTokens++ + null + } else { + id + } + }.toSet() if (invalidTokens > 0) { AppLog.warn(LOG_TAG, "skipped invalid tokens=$invalidTokens") @@ -369,10 +365,7 @@ class SponsorSyncManager( } private enum class SyncOutcome { - SUCCESS, - FAILED, - SKIPPED, - BUSY, + SUCCESS, FAILED, SKIPPED, BUSY, } private sealed class HistoryLoadResult { diff --git a/app/src/main/java/org/monogram/update/GitHubAppUpdate.kt b/app/src/main/java/org/monogram/update/GitHubAppUpdate.kt index f531ddecd..9b97005f8 100644 --- a/app/src/main/java/org/monogram/update/GitHubAppUpdate.kt +++ b/app/src/main/java/org/monogram/update/GitHubAppUpdate.kt @@ -13,6 +13,8 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.util.zip.ZipInputStream import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.monogram.core.common.AppLog @@ -104,6 +106,7 @@ suspend fun downloadHttpApk( onProgress(total) } } + currentCoroutineContext().ensureActive() if (!partial.renameTo(destination)) { error("cannot publish downloaded file") } @@ -111,7 +114,7 @@ suspend fun downloadHttpApk( } } catch (cancelled: CancellationException) { partial.delete() - Outcome.Err("cancelled", cancelled) + throw cancelled } catch (error: Exception) { partial.delete() Outcome.Err(error.message ?: "download failed", error) diff --git a/app/src/test/java/org/monogram/AccountNavigationIntentTest.kt b/app/src/test/java/org/monogram/AccountNavigationIntentTest.kt new file mode 100644 index 000000000..2e60a526b --- /dev/null +++ b/app/src/test/java/org/monogram/AccountNavigationIntentTest.kt @@ -0,0 +1,96 @@ +package org.monogram + +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.push.NotificationPresenter +import org.monogram.root.RootComponent + +class AccountNavigationIntentTest { + @Test + fun notificationIsHandledOnceButFreshTapAndOtherIntentsStillWork() { + val chat = NotificationPresenter.ACTION_OPEN_CHAT + val notifications = NotificationPresenter.ACTION_OPEN_NOTIFICATIONS + assertTrue( + shouldHandleIncomingIntent( + chat, + shareHandled = false, + notificationHandled = false + ) + ) + assertFalse( + shouldHandleIncomingIntent( + chat, + shareHandled = false, + notificationHandled = true + ) + ) + assertTrue( + shouldHandleIncomingIntent( + notifications, + shareHandled = true, + notificationHandled = false + ) + ) + assertFalse( + shouldHandleIncomingIntent( + notifications, + shareHandled = false, + notificationHandled = true + ) + ) + assertFalse( + shouldHandleIncomingIntent( + Intent.ACTION_SEND, + shareHandled = true, + notificationHandled = false + ) + ) + assertTrue( + shouldHandleIncomingIntent( + Intent.ACTION_SEND_MULTIPLE, + shareHandled = false, + notificationHandled = true + ) + ) + assertTrue( + shouldHandleIncomingIntent( + Intent.ACTION_VIEW, + shareHandled = true, + notificationHandled = true, + linkHandled = false + ) + ) + assertFalse( + shouldHandleIncomingIntent( + Intent.ACTION_VIEW, + shareHandled = false, + notificationHandled = false, + linkHandled = true + ) + ) + } + + @Test + fun switchDestinationSurvivesSavedStateEncoding() { + val destinations = listOf( + RootComponent.Config.Home, + RootComponent.Config.Proxy, + RootComponent.Config.Settings(), + RootComponent.Config.Settings(openProxy = true), + RootComponent.Config.Settings(openFolders = true), + RootComponent.Config.Settings(openNotifications = true), + ) + destinations.forEach { destination -> + assertEquals( + destination, + accountSwitchDestinationFromKey(accountSwitchDestinationKey(destination)) + ) + } + assertNull(accountSwitchDestinationFromKey(null)) + assertNull(accountSwitchDestinationFromKey("unknown")) + } +} diff --git a/app/src/test/java/org/monogram/AccountRegistryTest.kt b/app/src/test/java/org/monogram/AccountRegistryTest.kt new file mode 100644 index 000000000..255969d47 --- /dev/null +++ b/app/src/test/java/org/monogram/AccountRegistryTest.kt @@ -0,0 +1,422 @@ +package org.monogram + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.core.models.AuthSession +import org.monogram.core.models.PeerId +import org.monogram.push.NotificationPresenter +import java.nio.file.Files + +class AccountRegistryTest { + @Test + fun validLiveRegistryWinsOverStaleBackup() { + val root = Files.createTempDirectory("monogram-registry-live-wins").toFile() + try { + val registry = AccountRegistry(root) + registry.ensure(AuthSession(PeerId(11), 2)) + val live = root.resolve("accounts.json").readText() + root.resolve("accounts.json.bak").writeText( + """{"activeUserId":22,"accounts":[{"userId":22,"dcId":4,"sessionPath":"x","databaseName":"monogram-account-22.db","mediaPath":"x"}]}""" + ) + root.resolve("accounts.json").writeText(live) + + assertEquals(listOf(11L), AccountRegistry(root).load().map { it.userId }) + assertFalse(root.resolve("accounts.json.bak").exists()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun restoresBackupAfterInterruptedWrite() { + val root = Files.createTempDirectory("monogram-registry-recovery").toFile() + try { + AccountRegistry(root).ensure(AuthSession(PeerId(11), 2)) + val file = root.resolve("accounts.json") + assertTrue(file.renameTo(root.resolve("accounts.json.bak"))) + file.writeText("{") + val staged = root.resolve("accounts.json.tmp") + staged.writeText("interrupted") + + val restored = AccountRegistry(root) + assertEquals(listOf(11L), restored.load().map { it.userId }) + assertFalse(root.resolve("accounts.json.bak").exists()) + assertFalse(staged.exists()) + restored.ensure(AuthSession(PeerId(22), 4)) + assertEquals(listOf(11L, 22L), AccountRegistry(root).load().map { it.userId }) + } finally { + root.deleteRecursively() + } + } + + @Test + fun missingPrimaryRestoresBackupAndDiscardsStaging() { + val root = Files.createTempDirectory("monogram-registry-missing-primary").toFile() + try { + AccountRegistry(root).ensure(AuthSession(PeerId(11), 2)) + assertTrue(root.resolve("accounts.json").renameTo(root.resolve("accounts.json.bak"))) + root.resolve("accounts.json.tmp").writeText("interrupted") + + val restored = AccountRegistry(root) + assertEquals(listOf(11L), restored.load().map { it.userId }) + assertFalse(root.resolve("accounts.json.tmp").exists()) + restored.setActive(11) + assertEquals(11L, AccountRegistry(root).apply { load() }.activeUserId) + } finally { + root.deleteRecursively() + } + } + + @Test + fun invalidBackupDoesNotReplaceOrRemoveRegistryFiles() { + val root = Files.createTempDirectory("monogram-registry-invalid-backup").toFile() + try { + val primary = root.resolve("accounts.json") + val backup = root.resolve("accounts.json.bak") + val staged = root.resolve("accounts.json.tmp") + staged.writeText("interrupted") + val invalidState = + """{"activeUserId":0,"accounts":[{"userId":0,"dcId":2,"sessionPath":"x","databaseName":"monogram.db","mediaPath":"x"}]}""" + for (contents in listOf("{", invalidState)) { + primary.writeText("{") + backup.writeText(contents) + assertNotNull(runCatching { AccountRegistry(root).load() }.exceptionOrNull()) + assertEquals("{", primary.readText()) + assertEquals(contents, backup.readText()) + } + + assertTrue(primary.delete()) + assertNotNull(runCatching { AccountRegistry(root).load() }.exceptionOrNull()) + assertFalse(primary.exists()) + assertEquals(invalidState, backup.readText()) + assertEquals("interrupted", staged.readText()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun loneStagedRegistryIsNotOverwritten() { + val root = Files.createTempDirectory("monogram-registry-staged-only").toFile() + try { + val staged = root.resolve("accounts.json.tmp") + val contents = "interrupted registry write" + staged.writeText(contents) + + val registry = AccountRegistry(root) + assertNotNull(runCatching { registry.load() }.exceptionOrNull()) + assertNotNull(runCatching { + registry.ensure( + AuthSession( + PeerId(11), + 2 + ) + ) + }.exceptionOrNull()) + assertNotNull(runCatching { + registry.adoptLegacy( + AuthSession( + PeerId(11), + 2 + ) + ) + }.exceptionOrNull()) + assertTrue(registry.all().isEmpty()) + assertEquals(null, registry.activeUserId) + assertEquals(contents, staged.readText()) + assertFalse(root.resolve("accounts.json").exists()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun existingStagedRegistryIsRemovedAfterFailedWriteRecovery() { + val root = Files.createTempDirectory("monogram-registry-staged-write").toFile() + try { + val registry = AccountRegistry(root) + registry.ensure(AuthSession(PeerId(11), 2)) + val staged = root.resolve("accounts.json.tmp") + val contents = "interrupted registry write" + staged.writeText(contents) + + assertNotNull(runCatching { + registry.ensure( + AuthSession( + PeerId(22), + 4 + ) + ) + }.exceptionOrNull()) + assertFalse(staged.exists()) + assertEquals(listOf(11L), registry.all().map { it.userId }) + registry.ensure(AuthSession(PeerId(22), 4)) + assertEquals(listOf(11L, 22L), AccountRegistry(root).load().map { it.userId }) + } finally { + root.deleteRecursively() + } + } + + @Test + fun failedWriteRestoresInMemoryAccountsFromDurableRegistry() { + val root = Files.createTempDirectory("monogram-registry-write-failure").toFile() + try { + val registry = AccountRegistry(root) + registry.ensure(AuthSession(PeerId(11), 2)) + assertTrue(root.resolve("accounts.json.tmp").mkdir()) + + assertNotNull(runCatching { + registry.ensure( + AuthSession( + PeerId(22), + 4 + ) + ) + }.exceptionOrNull()) + assertNotNull(runCatching { registry.all() }.exceptionOrNull()) + val staged = root.resolve("accounts.json.tmp") + assertTrue(staged.isDirectory) + assertNotNull(runCatching { AccountRegistry(root).load() }.exceptionOrNull()) + assertTrue(staged.delete()) + assertEquals(listOf(11L), registry.load().map { it.userId }) + assertEquals(11L, registry.activeUserId) + registry.ensure(AuthSession(PeerId(22), 4)) + assertEquals(listOf(11L, 22L), AccountRegistry(root).load().map { it.userId }) + } finally { + root.deleteRecursively() + } + } + + @Test + fun failedWriteAndRecoveryCannotExposeUncommittedAccounts() { + val root = Files.createTempDirectory("monogram-registry-unreconciled").toFile() + try { + val registry = AccountRegistry(root) + registry.ensure(AuthSession(PeerId(11), 2)) + val primary = root.resolve("accounts.json") + val durable = primary.readText() + primary.writeText("{") + val staged = root.resolve("accounts.json.tmp") + staged.writeText("interrupted") + + val failure = + runCatching { registry.ensure(AuthSession(PeerId(22), 4)) }.exceptionOrNull() + assertNotNull(failure) + assertTrue(failure!!.suppressed.isNotEmpty()) + assertNotNull(runCatching { registry.all() }.exceptionOrNull()) + assertNotNull(runCatching { registry.get(11) }.exceptionOrNull()) + assertNotNull(runCatching { registry.activeUserId }.exceptionOrNull()) + assertNotNull(runCatching { registry.remove(11) }.exceptionOrNull()) + assertEquals("interrupted", staged.readText()) + + primary.writeText(durable) + assertEquals(listOf(11L), registry.load().map { it.userId }) + assertEquals(11L, registry.activeUserId) + assertFalse(staged.exists()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun startupRejectsRegisteredAccountsWithoutAnActiveAccount() { + val root = Files.createTempDirectory("monogram-registry-no-active").toFile() + try { + val registry = AccountRegistry(root) + assertEquals(null, registry.activeForStartup()) + registry.ensure(AuthSession(PeerId(11), 2), activate = false) + val restored = AccountRegistry(root).also { it.load() } + assertNotNull(runCatching { restored.activeForStartup() }.exceptionOrNull()) + assertEquals(listOf(11L), restored.all().map { it.userId }) + restored.setActive(11) + assertEquals(11L, restored.activeForStartup()?.userId) + } finally { + root.deleteRecursively() + } + } + + @Test + fun corruptRegistryDoesNotBecomeAnEmptyRegistry() { + val root = Files.createTempDirectory("monogram-corrupt-registry").toFile() + try { + val registry = AccountRegistry(root) + registry.ensure(AuthSession(PeerId(11), 2)) + root.resolve("accounts.json").writeText("{") + + val failure = runCatching { registry.load() }.exceptionOrNull() + assertNotNull(failure) + assertEquals(listOf(11L), registry.all().map { it.userId }) + } finally { + root.deleteRecursively() + } + } + + @Test + fun multiAccountNotificationsIdentifyTheirOwningAccountAndServiceEvents() { + assertEquals( + "New message · Account 22", + NotificationPresenter.accountQualifiedNotificationTitle("New message", "Account 22", 2), + ) + assertEquals( + "New message", + NotificationPresenter.accountQualifiedNotificationTitle("New message", "Account 22", 1), + ) + val servicePayload = org.monogram.core.common.push.PushPayload( + locKey = "SESSION_REVOKE", + locArgs = emptyList(), + title = "", + body = "", + chatId = null, + messageId = null, + maxId = null, + deletedIds = emptyList(), + mention = false, + silent = false, + sound = null, + attachB64 = null, + userId = null, + action = org.monogram.core.common.push.PushAction.SessionRevoke, + channelKind = org.monogram.core.common.push.PushChannelKind.Other, + ) + assertTrue( + NotificationPresenter.notificationIdFor(servicePayload, 11L) != + NotificationPresenter.notificationIdFor(servicePayload, 22L), + ) + } + + @Test + fun bubblesUseAccountQualifiedConversationShortcutIds() { + assertEquals("account:11:chat:7", NotificationPresenter.shortcutId(7L, 11L)) + assertEquals("account:22:chat:7", NotificationPresenter.shortcutId(7L, 22L)) + assertEquals("chat:7", NotificationPresenter.shortcutId(7L, null)) + } + + @Test + fun accountQualifiedNotificationIdsDoNotCollideAcrossAccounts() { + val first = NotificationPresenter.notificationId(7L, 11L) + val second = NotificationPresenter.notificationId(7L, 22L) + assertTrue(first != second) + } + + @Test + fun firstAuthorizationAdoptsLegacyAndAdditionalAuthorizationUsesKeyedStorage() { + val root = Files.createTempDirectory("monogram-authorized-accounts").toFile() + try { + val registry = AccountRegistry(root) + val first = registry.adoptAuthorized(AuthSession(PeerId(11), 2)) + val second = registry.adoptAuthorized(AuthSession(PeerId(22), 4)) + + assertEquals("monogram.db", first.databaseName) + assertEquals("monogram-account-22.db", second.databaseName) + assertFalse(first.sessionPath == second.sessionPath) + assertFalse(first.mediaPath == second.mediaPath) + } finally { + root.deleteRecursively() + } + } + + @Test + fun legacyAndAccountStorageSurviveRegistryReload() { + val root = Files.createTempDirectory("monogram-registry-validation").toFile() + try { + val registry = AccountRegistry(root) + registry.adoptAuthorized(AuthSession(PeerId(11), 2)) + registry.adoptAuthorized(AuthSession(PeerId(22), 4)) + + val accounts = AccountRegistry(root).load() + assertEquals("monogram.db", accounts[0].databaseName) + assertEquals(root.resolve("mtproto.session.json"), accounts[0].sessionPath) + assertEquals("monogram-account-22.db", accounts[1].databaseName) + assertEquals(root.resolve("accounts/22/mtproto.session.json"), accounts[1].sessionPath) + } finally { + root.deleteRecursively() + } + } + + @Test + fun unusableOrDuplicateRegistryDoesNotFallBackToLegacyStorage() { + val root = Files.createTempDirectory("monogram-registry-invalid").toFile() + try { + val file = root.resolve("accounts.json") + val invalid = + """{"activeUserId":0,"accounts":[{"userId":0,"dcId":2,"sessionPath":"x","databaseName":"monogram.db","mediaPath":"x"}]}""" + val duplicate = + """{"activeUserId":11,"accounts":[{"userId":11,"dcId":2,"sessionPath":"x","databaseName":"monogram.db","mediaPath":"x"},{"userId":11,"dcId":2,"sessionPath":"x","databaseName":"monogram-account-11.db","mediaPath":"x"}]}""" + for (contents in listOf(invalid, duplicate)) { + file.writeText(contents) + assertNotNull(runCatching { AccountRegistry(root).load() }.exceptionOrNull()) + assertEquals(contents, file.readText()) + } + } finally { + root.deleteRecursively() + } + } + + @Test + fun restoredPathsAreDerivedAndCannotBeRedirectedByRegistryContents() { + val root = Files.createTempDirectory("monogram-registry-paths").toFile() + try { + val registry = AccountRegistry(root) + registry.ensure(AuthSession(PeerId(11), 2)) + root.resolve("accounts.json").writeText( + """{"activeUserId":11,"accounts":[{"userId":11,"dcId":2,"sessionPath":"/outside/session","databaseName":"monogram-account-99.db","mediaPath":"/outside/media"}]}""" + ) + val restored = AccountRegistry(root) + val account = restored.load().single() + assertEquals(root.resolve("accounts/11/mtproto.session.json"), account.sessionPath) + assertEquals("monogram-account-11.db", account.databaseName) + assertEquals(root.resolve("accounts/11/media"), account.mediaPath) + } finally { + root.deleteRecursively() + } + } + + @Test + fun registeringWithoutActivationPreservesActiveAccountAcrossRestart() { + val root = Files.createTempDirectory("monogram-account-staging").toFile() + try { + val registry = AccountRegistry(root) + registry.ensure(AuthSession(PeerId(11), 2)) + registry.ensure(AuthSession(PeerId(22), 4), activate = false) + val restored = AccountRegistry(root).also { it.load() } + assertEquals(listOf(11L, 22L), restored.all().map { it.userId }) + assertEquals(11L, restored.activeUserId) + restored.setActive(22) + restored.ensure(AuthSession(PeerId(11), 3), activate = false) + assertEquals(22L, AccountRegistry(root).also { it.load() }.activeUserId) + restored.ensure(AuthSession(PeerId(11), 4)) + val reactivated = AccountRegistry(root).also { it.load() } + assertEquals(11L, reactivated.activeUserId) + assertEquals(4, reactivated.get(11)?.dcId) + } finally { + root.deleteRecursively() + } + } + + @Test + fun persistsAccountsAndKeepsStoragePathsIndependent() { + val root = Files.createTempDirectory("monogram-accounts").toFile() + try { + val first = AccountRegistry(root) + first.ensure(AuthSession(PeerId(11), 2)) + first.ensure(AuthSession(PeerId(22), 4)) + first.setActive(22) + + val restored = AccountRegistry(root) + assertEquals(listOf(11L, 22L), restored.load().map { it.userId }) + assertEquals(22L, restored.activeUserId) + assertNotNull(restored.get(11)?.sessionPath) + assertFalse(restored.get(11)!!.sessionPath == restored.get(22)!!.sessionPath) + + val removed = restored.remove(11) + assertEquals(11L, removed?.userId) + assertEquals(listOf(22L), restored.all().map { it.userId }) + assertTrue(restored.get(11) == null) + } finally { + root.deleteRecursively() + } + } +} diff --git a/app/src/test/java/org/monogram/AccountRemovalTest.kt b/app/src/test/java/org/monogram/AccountRemovalTest.kt new file mode 100644 index 000000000..2d7194918 --- /dev/null +++ b/app/src/test/java/org/monogram/AccountRemovalTest.kt @@ -0,0 +1,97 @@ +package org.monogram + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files + +class AccountRemovalTest { + @Test + fun adoptionRejectsExistingAccountStorageWithoutDeletingIt() { + val root = Files.createTempDirectory("monogram-adoption-storage").toFile() + try { + val files = root.resolve("files") + val noBackup = root.resolve("no-backup") + val database = root.resolve("databases/monogram-account-22.db") + val paths = listOf( + files.resolve("accounts/22"), + files.resolve("session-identity-22.json"), + noBackup.resolve("outgoing-media/22"), + database, + root.resolve("databases/monogram-account-22.db-journal"), + root.resolve("databases/monogram-account-22.db-wal"), + root.resolve("databases/monogram-account-22.db-shm"), + ) + requireVacantAccountStorage(files, noBackup, database, 22) + paths.forEach { path -> + path.parentFile.mkdirs() + path.writeText("existing") + assertTrue(runCatching { + requireVacantAccountStorage( + files, + noBackup, + database, + 22 + ) + }.isFailure) + assertTrue(path.readText() == "existing") + path.delete() + } + } finally { + root.deleteRecursively() + } + } + + @Test + fun legacyOutgoingCleanupDoesNotEnterOtherAccountDirectories() { + val root = Files.createTempDirectory("monogram-legacy-outgoing").toFile().canonicalFile + try { + val id = "123e4567-e89b-12d3-a456-426614174000" + val legacy = root.resolve("$id.bin").apply { writeText("legacy") } + val partial = root.resolve("$id.bin.part").apply { writeText("partial") } + val unrelated = root.resolve("notes.bin").apply { writeText("shared") } + val other = root.resolve("22/$id.bin").apply { + parentFile.mkdirs() + writeText("other") + } + + deleteLegacyOutgoingMedia(root) + + assertFalse(legacy.exists()) + assertFalse(partial.exists()) + assertTrue(unrelated.exists()) + assertTrue(other.exists()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun legacyFileCleanupPreservesOtherAccountAndSharedFiles() { + val root = Files.createTempDirectory("monogram-legacy-removal").toFile() + try { + val session = root.resolve("mtproto.session.json").apply { writeText("session") } + val sidecars = listOf( + "mtproto.session.json.tmp", "mtproto.session.tmp", "mtproto.session.json.key", + "mtproto.session.json.key.bak", "mtproto.session.json.key.new", + ).map { root.resolve(it).apply { writeText("sidecar") } } + val identity = root.resolve("session-identity.json").apply { writeText("identity") } + val shared = root.resolve("shared.txt").apply { writeText("shared") } + val other = root.resolve("accounts/22/mtproto.session.json").apply { + parentFile.mkdirs() + writeText("other") + } + + deleteLegacyAccountFiles(root) + + assertFalse(session.exists()) + assertTrue(sidecars.none { it.exists() }) + assertFalse(identity.exists()) + assertTrue(shared.exists()) + assertTrue(other.exists()) + deleteLegacyAccountFiles(root) + } finally { + root.deleteRecursively() + } + } +} diff --git a/app/src/test/java/org/monogram/AccountWorkManagerTest.kt b/app/src/test/java/org/monogram/AccountWorkManagerTest.kt new file mode 100644 index 000000000..06aab6bf6 --- /dev/null +++ b/app/src/test/java/org/monogram/AccountWorkManagerTest.kt @@ -0,0 +1,83 @@ +package org.monogram + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +class AccountWorkManagerTest { + @Test + fun restartsRegisteredWorkerAfterSessionLossStopsIt() = runBlocking { + val starts = AtomicInteger() + var worker: AccountBackgroundWork? = null + val manager = AccountWorkManager( + scope = CoroutineScope(Dispatchers.Unconfined), + accountId = String::toLong, + create = { + object : AccountBackgroundWork { + var running = false + + init { + worker = this + } + + override fun isRunning(): Boolean = running + override fun start(scope: CoroutineScope) { + running = true + starts.incrementAndGet() + } + + override fun resume() = Unit + override suspend fun stop() { + running = false + } + } + }, + ) + + manager.start("11") + worker!!.stop() + manager.start("11") + + assertEquals(2, starts.get()) + assertEquals(setOf(11L), manager.runningAccountIds()) + } + + @Test + fun startsAndStopsEachAccountWorkerIndependently() = runBlocking { + val started = AtomicInteger() + val stopped = AtomicInteger() + val manager = AccountWorkManager( + scope = CoroutineScope(Dispatchers.Unconfined), + accountId = String::toLong, + create = { + object : AccountBackgroundWork { + var running = false + override fun isRunning(): Boolean = running + override fun start(scope: CoroutineScope) { + running = true + started.incrementAndGet() + } + + override fun resume() = Unit + override suspend fun stop() { + running = false + stopped.incrementAndGet() + } + } + }, + ) + + manager.start("11") + manager.start("22") + assertEquals(setOf(11L, 22L), manager.runningAccountIds()) + manager.stop(11L) + assertEquals(setOf(22L), manager.runningAccountIds()) + manager.start("11") + assertEquals(setOf(11L, 22L), manager.runningAccountIds()) + assertEquals(3, started.get()) + assertEquals(1, stopped.get()) + } +} diff --git a/app/src/test/java/org/monogram/StartupAuthTest.kt b/app/src/test/java/org/monogram/StartupAuthTest.kt new file mode 100644 index 000000000..1428a6c32 --- /dev/null +++ b/app/src/test/java/org/monogram/StartupAuthTest.kt @@ -0,0 +1,45 @@ +package org.monogram + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.core.common.Outcome +import org.monogram.core.models.AuthSession +import org.monogram.core.models.PeerId +import org.monogram.root.recoveredProfileSession + +class StartupAuthTest { + @Test + fun explicitSignOutTakesPrecedenceOverNativeAuthorization() = runBlocking { + var nativeCalled = false + val home = resolveStartOnHome( + sessionAuthorized = { true }, + locallyAuthorized = { nativeCalled = true; Outcome.Ok(true) }, + explicitlySignedOut = { true }, + ) + assertFalse(home) + assertFalse(nativeCalled) + } + + @Test + fun profileRecoveryRequiresMatchingAccountAndValidDataCenter() { + val user = PeerId(10L) + assertEquals(AuthSession(user, 2), recoveredProfileSession(user, 10L, 2)) + assertNull(recoveredProfileSession(user, 11L, 2)) + assertNull(recoveredProfileSession(user, 10L, 0)) + assertNull(recoveredProfileSession(user, null, 2)) + } + + @Test + fun missingSignOutMarkerStillAllowsExistingLocalSession() = runBlocking { + assertTrue( + resolveStartOnHome( + sessionAuthorized = { false }, + locallyAuthorized = { Outcome.Ok(true) }, + ) + ) + } +} diff --git a/app/src/test/java/org/monogram/StartupSequenceTest.kt b/app/src/test/java/org/monogram/StartupSequenceTest.kt new file mode 100644 index 000000000..1937a378b --- /dev/null +++ b/app/src/test/java/org/monogram/StartupSequenceTest.kt @@ -0,0 +1,159 @@ +package org.monogram + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class StartupSequenceTest { + @Test + fun firstPaintWaitsForClientInitialization() = runBlocking { + val releaseClient = CompletableDeferred() + var cleanupStarted = false + val startup = async(start = CoroutineStart.UNDISPATCHED) { + runFirstPaintStartup( + this, StartupSteps( + openDatabase = {}, + createClient = { releaseClient.await() }, + installImageCache = {}, + cleanup = { cleanupStarted = true }, + prewarm = {}, + mediaMigration = {}, + installDownloadSettings = {}, + createMedia = {}, + createPush = {}, + ) + ) + } + + assertFalse(startup.isCompleted) + assertFalse(cleanupStarted) + releaseClient.complete(Unit) + startup.await() + assertTrue(cleanupStarted) + } + + @Test + fun databaseAndClientAreReadyBeforeCleanup() = runBlocking { + val order = mutableListOf() + runFirstPaintStartup( + this, StartupSteps( + openDatabase = { order += "database" }, + createClient = { order += "client" }, + installImageCache = {}, + cleanup = { order += "cleanup" }, + prewarm = {}, + mediaMigration = {}, + installDownloadSettings = {}, + createMedia = {}, + createPush = {}, + ) + ) + assertEquals(listOf("database", "client", "cleanup"), order) + } + + @Test + fun cacheMaintenanceIsPostReadyAndDoesNotBlockPrewarmOrServices() = runBlocking { + val releaseMaintenance = CompletableDeferred() + var prewarmStarted = false + var maintenanceStarted = false + var servicesStarted = false + val steps = StartupSteps( + openDatabase = {}, + createClient = {}, + installImageCache = {}, + cleanup = {}, + prewarm = { prewarmStarted = true }, + mediaMigration = {}, + maintainCache = { + maintenanceStarted = true + releaseMaintenance.await() + }, + installDownloadSettings = {}, + createMedia = {}, + createPush = {}, + startPush = { servicesStarted = true }, + ) + try { + runFirstPaintStartup(this, steps) + yield() + assertTrue(prewarmStarted) + assertFalse(maintenanceStarted) + startAfterFirstPaint(steps, this) + yield() + assertTrue(maintenanceStarted) + assertTrue(servicesStarted) + assertFalse(releaseMaintenance.isCompleted) + } finally { + releaseMaintenance.complete(Unit) + } + } + + @Test + fun firstPaintCancellationDoesNotStartPrewarm() = runBlocking { + val cancellation = CancellationException("cancelled") + var prewarmStarted = false + val thrown = try { + runFirstPaintStartup( + this, StartupSteps( + openDatabase = {}, + createClient = {}, + installImageCache = {}, + cleanup = { throw cancellation }, + prewarm = { prewarmStarted = true }, + mediaMigration = {}, + installDownloadSettings = {}, + createMedia = {}, + createPush = {}, + ) + ) + null + } catch (caught: CancellationException) { + caught + } + assertEquals(cancellation.message, thrown?.message) + assertFalse(prewarmStarted) + } + + @Test + fun unavailableSecondaryAccountDoesNotBlockOtherAccounts() = runBlocking { + val accounts = (1L..3L).map(::account) + val attempted = mutableListOf() + var unavailable = 0 + + val ready = + prepareStartupAccounts(accounts, activeUserId = 1, onUnavailable = { unavailable++ }) { + attempted += it.userId + if (it.userId == 2L) throw IllegalStateException("storage unavailable") + } + + assertEquals(listOf(1L, 2L, 3L), attempted) + assertEquals(setOf(1L, 3L), ready) + assertEquals(1, unavailable) + } + + @Test + fun activeAccountFailureAndCancellationPropagate() = runBlocking { + val failure = IllegalStateException("active account unavailable") + val cancellation = CancellationException("cancelled") + for (error in listOf(failure, cancellation)) { + val thrown = try { + prepareStartupAccounts(listOf(account(1)), 1, {}) { throw error } + null + } catch (caught: Exception) { + caught + } + assertTrue(thrown === error) + } + } + + private fun account(id: Long) = + AccountRecord(id, 1, File("session-$id"), "account-$id.db", File("media-$id")) +} diff --git a/app/src/test/java/org/monogram/push/AccountPushRouterTest.kt b/app/src/test/java/org/monogram/push/AccountPushRouterTest.kt new file mode 100644 index 000000000..7eb2323ac --- /dev/null +++ b/app/src/test/java/org/monogram/push/AccountPushRouterTest.kt @@ -0,0 +1,88 @@ +package org.monogram.push + +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.core.common.Outcome +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class AccountPushRouterTest { + @Test + fun encryptedPushRoutesToDecryptingAccountAndForegroundTransitionsStayScoped() { + val router = AccountPushRouter() + val first = FakeHandler() + val second = FakeHandler() + router.register(11L, first) + router.register(22L, second) + router.setForeground(true, 11L) + + assertTrue(first.foregroundState) + assertFalse(second.foregroundState) + first.decryptResult = Outcome.Err("wrong account") + second.decryptResult = Outcome.Ok(payload(22L)) + awaitCompletion { router.handleFcm("cipher", it) } + assertEquals(listOf(payload(22L)), second.delivered) + assertTrue(first.delivered.isEmpty()) + + router.setForeground(false, 11L) + assertFalse(first.foregroundState) + assertFalse(second.foregroundState) + } + + @Test + fun wakeFansOutAndCompletesAfterEveryAccount() { + val router = AccountPushRouter() + val first = FakeHandler() + val second = FakeHandler() + router.register(11L, first) + router.register(22L, second) + + awaitCompletion { router.handleFcm(null, it) } + + assertEquals(1, first.wakes) + assertEquals(1, second.wakes) + } + + private fun payload(userId: Long) = + """{"loc_key":"MESSAGE_TEXT","loc_args":["Ada","hello"],"user_id":"$userId","custom":{"msg_id":"3","channel_id":"9"}}""" + + private fun awaitCompletion(start: ((() -> Unit) -> Unit)) { + val latch = CountDownLatch(1) + start(latch::countDown) + assertTrue("FCM routing did not complete", latch.await(3, TimeUnit.SECONDS)) + } + + private class FakeHandler : AccountPushHandler { + @JvmField + var foregroundState = false + var wakes = 0 + val delivered = mutableListOf() + var decryptResult: Outcome = Outcome.Err("not encrypted") + override fun setForeground(value: Boolean) { + foregroundState = value + } + + override suspend fun unregisterPush() = Unit + override fun stop() = Unit + override fun onFcmToken(token: String, onComplete: (() -> Unit)?) { + onComplete?.invoke() + } + + override fun handleFcm(payload: String?, onComplete: (() -> Unit)?) { + if (payload == null) wakes++ + onComplete?.invoke() + } + + override fun decryptFcmPayload(cipher: String): Outcome = decryptResult + + override fun handleDecryptedFcmPayload(json: String, onComplete: (() -> Unit)?) { + delivered += json + onComplete?.invoke() + } + + override fun handleNotificationAction(intent: Intent, done: () -> Unit) = done() + } +} diff --git a/app/src/test/java/org/monogram/push/NotificationActionOwnerTest.kt b/app/src/test/java/org/monogram/push/NotificationActionOwnerTest.kt new file mode 100644 index 000000000..27264622a --- /dev/null +++ b/app/src/test/java/org/monogram/push/NotificationActionOwnerTest.kt @@ -0,0 +1,39 @@ +package org.monogram.push + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.monogram.AccountRecord +import java.io.File + +class NotificationActionOwnerTest { + private fun account(id: Long, legacy: Boolean = false) = AccountRecord( + userId = id, + dcId = 1, + sessionPath = File("session-$id"), + databaseName = if (legacy) "monogram.db" else "monogram-account-$id.db", + mediaPath = File("media-$id"), + ) + + @Test + fun qualifiedActionsRequireRegisteredOwner() { + val accounts = listOf(account(1, legacy = true), account(2)) + assertEquals(2L, notificationActionOwner(2, accounts)) + assertNull(notificationActionOwner(3, accounts)) + } + + @Test + fun notificationTapSwitchesToOwningAccountBeforeOpeningChat() { + val accounts = listOf(account(1, legacy = true), account(2)) + assertEquals(NotificationAccountRoute.Switch(2), notificationAccountRoute(2, 1, accounts)) + assertEquals(NotificationAccountRoute.Open, notificationAccountRoute(2, 2, accounts)) + assertEquals(NotificationAccountRoute.Reject, notificationAccountRoute(3, 1, accounts)) + } + + @Test + fun unqualifiedActionsRequireSingleLegacyAccount() { + assertEquals(1L, notificationActionOwner(0, listOf(account(1, legacy = true)))) + assertNull(notificationActionOwner(0, listOf(account(1)))) + assertNull(notificationActionOwner(0, listOf(account(1, legacy = true), account(2)))) + } +} diff --git a/app/src/test/java/org/monogram/push/NotificationReadGuardTest.kt b/app/src/test/java/org/monogram/push/NotificationReadGuardTest.kt new file mode 100644 index 000000000..35cacd7ca --- /dev/null +++ b/app/src/test/java/org/monogram/push/NotificationReadGuardTest.kt @@ -0,0 +1,97 @@ +package org.monogram.push + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.core.common.push.parsePushPayload +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.models.Chat +import org.monogram.core.models.PeerId + +class NotificationReadGuardTest { + private class Cache(var inbox: Int = 0, var topic: Int = 0) : OfflineWarmup() { + override suspend fun chat(chatId: PeerId): Chat = + Chat(chatId, "Chat", readInboxMaxId = inbox) + + override suspend fun discussionReadMax(chatId: PeerId, topId: Int): Int = topic + } + + @Test + fun checksAccountAndTopicCursorsWithoutSuppressingUnknownIds() = runBlocking { + val first = Cache(inbox = 20, topic = 30) + val second = Cache(inbox = 5) + assertTrue(NotificationReadGuard.isRead(first, 1, 20, null)) + assertFalse(NotificationReadGuard.isRead(second, 1, 20, null)) + assertTrue(NotificationReadGuard.isRead(first, 1, 30, 7)) + assertFalse(NotificationReadGuard.isRead(first, 1, 30, null)) + assertFalse(NotificationReadGuard.isRead(first, 1, 0, 7)) + assertFalse(NotificationReadGuard.isRead(first, null, 20, 7)) + } + + @Test + fun readCursorAppliesOnlyToUnscheduledMessageNotifications() { + fun payload(key: String, scheduled: Boolean = false) = parsePushPayload( + """{"loc_key":"$key","custom":{"from_id":1,"msg_id":20,"schedule":$scheduled}}""", + ) + assertTrue(NotificationReadGuard.appliesTo(payload("MESSAGE_TEXT"))) + assertTrue(NotificationReadGuard.appliesTo(payload("CHAT_MESSAGE_PHOTO"))) + assertTrue(NotificationReadGuard.appliesTo(payload("CHANNEL_MESSAGE_TEXT"))) + assertFalse(NotificationReadGuard.appliesTo(payload("MESSAGE_TEXT", scheduled = true))) + assertFalse(NotificationReadGuard.appliesTo(payload("MESSAGE_REACTION"))) + assertFalse(NotificationReadGuard.appliesTo(payload("STORY_NOTEXT"))) + assertFalse(NotificationReadGuard.appliesTo(payload("PHONE_CALL_REQUEST"))) + assertFalse(NotificationReadGuard.appliesTo(payload("AUTH_UNKNOWN"))) + } + + @Test + fun committedReadIsVisibleBeforeWaitingPostAndAccountsAreIndependent() = runBlocking { + val first = Cache() + val second = Cache() + val entered = CompletableDeferred() + val finishCommit = CompletableDeferred() + val commit = async(start = CoroutineStart.UNDISPATCHED) { + NotificationReadGuard.withAccount(first) { + entered.complete(Unit) + finishCommit.await() + first.inbox = 20 + } + } + entered.await() + val post = async(start = CoroutineStart.UNDISPATCHED) { + NotificationReadGuard.withAccount(first) { + !NotificationReadGuard.isRead(first, 1, 20, null) + } + } + assertFalse(post.isCompleted) + assertTrue(NotificationReadGuard.withAccount(second) { true }) + finishCommit.complete(Unit) + commit.await() + assertFalse(post.await()) + } + + @Test + fun delayedRepaintRechecksReadAfterInitialPost() = runBlocking { + val cache = Cache() + val initial = NotificationReadGuard.withAccount(cache) { + !NotificationReadGuard.isRead(cache, 1, 20, null) + } + assertTrue(initial) + NotificationReadGuard.withAccount(cache) { cache.inbox = 20 } + val repaint = NotificationReadGuard.withAccount(cache) { + !NotificationReadGuard.isRead(cache, 1, 20, null) + } + assertFalse(repaint) + } + + @Test + fun legacyCleanupTargetsOnlyUnownedNotificationChildrenOrSummary() { + assertTrue(isLegacyNotification(0, true, false)) + assertTrue(isLegacyNotification(0, false, true)) + assertFalse(isLegacyNotification(123, true, false)) + assertFalse(isLegacyNotification(0, false, false)) + } +} diff --git a/app/src/test/java/org/monogram/root/TabletNavigationTest.kt b/app/src/test/java/org/monogram/root/TabletNavigationTest.kt index fb591c977..4f27d7461 100644 --- a/app/src/test/java/org/monogram/root/TabletNavigationTest.kt +++ b/app/src/test/java/org/monogram/root/TabletNavigationTest.kt @@ -4,6 +4,47 @@ import org.junit.Assert.assertEquals import org.junit.Test class TabletNavigationTest { + @Test + fun additionalAccountSignInIsAnAuthArea() { + assertEquals(true, RootComponent.Config.AddAccount.isAuthArea()) + assertEquals(false, RootComponent.Config.Home.isAuthArea()) + } + + @Test + fun accountSwitchRetainsOnlySafeDestinationAndFreshBackStack() { + val settings = RootComponent.Config.Settings() + val proxy = RootComponent.Config.Settings(openProxy = true) + assertEquals( + RootComponent.Config.Home, + accountSwitchDestination(RootComponent.Config.Dialog(10), null) + ) + assertEquals( + RootComponent.Config.Home, + accountSwitchDestination(RootComponent.Config.Profile(10), null) + ) + assertEquals( + RootComponent.Config.Home, + accountSwitchDestination(RootComponent.Config.ScheduledQueue(10), null) + ) + assertEquals( + settings, + accountSwitchDestination(settings, org.monogram.feature.settings.SettingsPage.Home) + ) + assertEquals( + proxy, + accountSwitchDestination(settings, org.monogram.feature.settings.SettingsPage.Proxy) + ) + assertEquals( + RootComponent.Config.Proxy, + accountSwitchDestination(RootComponent.Config.Proxy, null) + ) + assertEquals( + listOf(RootComponent.Config.Home), + accountSwitchStack(RootComponent.Config.Home) + ) + assertEquals(listOf(RootComponent.Config.Home, settings), accountSwitchStack(settings)) + } + @Test fun selectingChatFromVisibleListReplacesDetailAndItsNestedScreens() { val initial = listOf( diff --git a/core/common/src/main/java/org/monogram/core/common/push/NotificationLocalStore.kt b/core/common/src/main/java/org/monogram/core/common/push/NotificationLocalStore.kt index 81a5baa88..4b80e24e8 100644 --- a/core/common/src/main/java/org/monogram/core/common/push/NotificationLocalStore.kt +++ b/core/common/src/main/java/org/monogram/core/common/push/NotificationLocalStore.kt @@ -2,76 +2,155 @@ package org.monogram.core.common.push import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit import org.monogram.core.models.NotifyDefaults import org.monogram.core.models.NotifyException import org.monogram.core.models.NotifySettings import org.monogram.core.models.PushDebugState import org.monogram.core.models.PushTokenType -class NotificationLocalStore(context: Context) { - private val prefs: SharedPreferences = - context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +internal fun notificationPreferenceValues(values: Map): Map = + values.filterValues { value -> + value is Boolean || value is Int || value is Long || value is Float || value is String || value is Set<*> && value.all { it is String } + }.mapValues { (_, value) -> value ?: "" } + +class NotificationLocalStore( + context: Context, + accountUserId: Long? = null, + migrateLegacy: Boolean = false, +) { + private val applicationContext = context.applicationContext + private val prefs: SharedPreferences = applicationContext.getSharedPreferences( + accountUserId?.let { "${PREFS}_$it" } ?: PREFS, + Context.MODE_PRIVATE, + ).also { scoped -> + if (accountUserId != null && migrateLegacy && scoped.all.isEmpty()) { + val legacy = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + if (legacy.all.isNotEmpty()) { + scoped.edit(commit = true) { + notificationPreferenceValues(legacy.all).forEach { (key, value) -> + when (value) { + is Boolean -> putBoolean(key, value) + is Int -> putInt(key, value) + is Long -> putLong(key, value) + is Float -> putFloat(key, value) + is String -> putString(key, value) + is Set<*> -> putStringSet( + key, value.filterIsInstance().toSet() + ) + } + } + } + } + } + } + + var notificationsEnabled: Boolean + get() = prefs.getBoolean(NOTIFICATIONS_ENABLED, true) + set(value) { + prefs.edit { putBoolean(NOTIFICATIONS_ENABLED, value) } + } var inAppSound: Boolean get() = prefs.getBoolean(INAPP_SOUND, true) - set(value) { prefs.edit().putBoolean(INAPP_SOUND, value).apply() } + set(value) { + prefs.edit { putBoolean(INAPP_SOUND, value) } + } var inAppVibrate: Boolean get() = prefs.getBoolean(INAPP_VIBRATE, true) - set(value) { prefs.edit().putBoolean(INAPP_VIBRATE, value).apply() } + set(value) { + prefs.edit { putBoolean(INAPP_VIBRATE, value) } + } var inAppPreview: Boolean get() = prefs.getBoolean(INAPP_PREVIEW, true) - set(value) { prefs.edit().putBoolean(INAPP_PREVIEW, value).apply() } + set(value) { + prefs.edit { putBoolean(INAPP_PREVIEW, value) } + } var inChatSound: Boolean get() = prefs.getBoolean(INCHAT_SOUND, true) - set(value) { prefs.edit().putBoolean(INCHAT_SOUND, value).apply() } + set(value) { + prefs.edit { putBoolean(INCHAT_SOUND, value) } + } var inAppPriority: Boolean get() = prefs.getBoolean(INAPP_PRIORITY, true) - set(value) { prefs.edit().putBoolean(INAPP_PRIORITY, value).apply() } + set(value) { + prefs.edit { putBoolean(INAPP_PRIORITY, value) } + } var pinnedEnabled: Boolean get() = prefs.getBoolean(PINNED, true) - set(value) { prefs.edit().putBoolean(PINNED, value).apply() } + set(value) { + prefs.edit { putBoolean(PINNED, value) } + } var contactJoinedEnabled: Boolean get() = prefs.getBoolean(CONTACT_JOINED, true) - set(value) { prefs.edit().putBoolean(CONTACT_JOINED, value).apply() } + set(value) { + prefs.edit { putBoolean(CONTACT_JOINED, value) } + } var storiesEnabled: Boolean get() = prefs.getBoolean(STORIES, true) - set(value) { prefs.edit().putBoolean(STORIES, value).apply() } + set(value) { + prefs.edit { putBoolean(STORIES, value) } + } var reactionsEnabled: Boolean get() = prefs.getBoolean(REACTIONS, true) - set(value) { prefs.edit().putBoolean(REACTIONS, value).apply() } + set(value) { + prefs.edit { putBoolean(REACTIONS, value) } + } var giftsEnabled: Boolean get() = prefs.getBoolean(GIFTS, true) - set(value) { prefs.edit().putBoolean(GIFTS, value).apply() } + set(value) { + prefs.edit { putBoolean(GIFTS, value) } + } var badgeEnabled: Boolean get() = prefs.getBoolean(BADGE, true) - set(value) { prefs.edit().putBoolean(BADGE, value).apply() } + set(value) { + prefs.edit { putBoolean(BADGE, value) } + } var badgeMuted: Boolean get() = prefs.getBoolean(BADGE_MUTED, true) - set(value) { prefs.edit().putBoolean(BADGE_MUTED, value).apply() } + set(value) { + prefs.edit { putBoolean(BADGE_MUTED, value) } + } var badgeMessages: Boolean get() = prefs.getBoolean(BADGE_MESSAGES, true) - set(value) { prefs.edit().putBoolean(BADGE_MESSAGES, value).apply() } + set(value) { + prefs.edit { putBoolean(BADGE_MESSAGES, value) } + } var repeatMinutes: Int get() = prefs.getInt(REPEAT, 0) - set(value) { prefs.edit().putInt(REPEAT, value).apply() } + set(value) { + prefs.edit { putInt(REPEAT, value) } + } var callsVibrate: String get() = prefs.getString(CALLS_VIBRATE, "default").orEmpty() - set(value) { prefs.edit().putString(CALLS_VIBRATE, value).apply() } + set(value) { + prefs.edit { putString(CALLS_VIBRATE, value) } + } var callsRingtone: String get() = prefs.getString(CALLS_RINGTONE, "default").orEmpty() - set(value) { prefs.edit().putString(CALLS_RINGTONE, value).apply() } + set(value) { + prefs.edit { putString(CALLS_RINGTONE, value) } + } var noMuted: Boolean get() = prefs.getBoolean(NO_MUTED, true) - set(value) { prefs.edit().putBoolean(NO_MUTED, value).apply() } + set(value) { + prefs.edit { putBoolean(NO_MUTED, value) } + } var bubblesEnabled: Boolean get() = prefs.getBoolean(BUBBLES, false) - set(value) { prefs.edit().putBoolean(BUBBLES, value).apply() } + set(value) { + prefs.edit { putBoolean(BUBBLES, value) } + } var appSandbox: Boolean get() = prefs.getBoolean(APP_SANDBOX, false) - set(value) { prefs.edit().putBoolean(APP_SANDBOX, value).apply() } + set(value) { + prefs.edit { putBoolean(APP_SANDBOX, value) } + } var lastShownChatId: Long get() = prefs.getLong(LAST_SHOWN_CHAT, 0L) - set(value) { prefs.edit().putLong(LAST_SHOWN_CHAT, value).apply() } + set(value) { + prefs.edit { putLong(LAST_SHOWN_CHAT, value) } + } fun notificationLedger(chatId: Long): NotificationLedger = NotificationLedger.decode(prefs.getString("notification_ledger_$chatId", null)) @@ -79,8 +158,7 @@ class NotificationLocalStore(context: Context) { fun saveNotificationLedger(chatId: Long, ledger: NotificationLedger) { if (!prefs.edit().putString("notification_ledger_$chatId", ledger.encode()).commit()) { org.monogram.core.common.AppLog.warn( - "notify", - "notification identity persistence failed" + "notify", "notification identity persistence failed" ) } } @@ -91,71 +169,66 @@ class NotificationLocalStore(context: Context) { }.toSet() fun clearNotificationLedgers() { - val editor = prefs.edit() - prefs.all.keys.filter { it.startsWith("notification_ledger_") }.forEach(editor::remove) - editor.commit() + prefs.edit { + prefs.all.keys.filter { it.startsWith("notification_ledger_") }.forEach(this::remove) + } } fun categoryVibrate(kind: String): Boolean = prefs.getBoolean("${kind}_vibrate", true) fun setCategoryVibrate(kind: String, value: Boolean) { - prefs.edit().putBoolean("${kind}_vibrate", value).apply() + prefs.edit { putBoolean("${kind}_vibrate", value) } } + fun categoryLed(kind: String): Boolean = prefs.getBoolean("${kind}_led", true) fun setCategoryLed(kind: String, value: Boolean) { - prefs.edit().putBoolean("${kind}_led", value).apply() + prefs.edit { putBoolean("${kind}_led", value) } } + fun categoryPriorityHigh(kind: String): Boolean = prefs.getBoolean("${kind}_priority", true) fun setCategoryPriorityHigh(kind: String, value: Boolean) { - prefs.edit().putBoolean("${kind}_priority", value).apply() + prefs.edit { putBoolean("${kind}_priority", value) } } + /** Whether notifications of this category may be shown as a heads-up popup. */ fun categoryPopup(kind: String): Boolean = prefs.getBoolean("${kind}_popup", true) fun setCategoryPopup(kind: String, value: Boolean) { - prefs.edit().putBoolean("${kind}_popup", value).apply() + prefs.edit { putBoolean("${kind}_popup", value) } } fun mutedFolders(): Set = - prefs.getStringSet(MUTED_FOLDERS, emptySet()) - ?.mapNotNull { it.toIntOrNull() } - ?.toSet() + prefs.getStringSet(MUTED_FOLDERS, emptySet())?.mapNotNull { it.toIntOrNull() }?.toSet() .orEmpty() /** Badge settings used for notification numbers (badge show/count, from the settings screen). */ - fun badgeSettings(): BadgeSettings = BadgeSettings(enabled = badgeEnabled, countMessages = badgeMessages) + fun badgeSettings(): BadgeSettings = + BadgeSettings(enabled = badgeEnabled, countMessages = badgeMessages) /** Local per-chat notification modes; chats without an entry follow their category. */ fun peerModes(): Map = - prefs.getStringSet(PEER_MODES, emptySet()) - .orEmpty() - .mapNotNull { PeerNotificationModeCodec.decode(it) } - .toMap() + prefs.getStringSet(PEER_MODES, emptySet()).orEmpty() + .mapNotNull { PeerNotificationModeCodec.decode(it) }.toMap() fun peerMode(chatId: Long): PeerNotificationMode? = peerModes()[chatId] fun setPeerMode(chatId: Long, mode: PeerNotificationMode?) { - val next = PeerNotificationModeCodec.upsert(prefs.getStringSet(PEER_MODES, emptySet()).orEmpty(), chatId, mode) - prefs.edit().putStringSet(PEER_MODES, next).apply() + val next = PeerNotificationModeCodec.upsert( + prefs.getStringSet(PEER_MODES, emptySet()).orEmpty(), chatId, mode + ) + prefs.edit { putStringSet(PEER_MODES, next) } } @Synchronized fun cacheNotifySettings( - kind: String, - chatId: Long, - settings: NotifySettings, - topicId: Int? = null + kind: String, chatId: Long, settings: NotifySettings, topicId: Int? = null ) { if (kind == "peer") { val complete = prefs.contains(NOTIFY_EXCEPTIONS) && prefs.getBoolean(NOTIFY_EXCEPTIONS_READY, true) notifyExceptions = notifyExceptions.orEmpty() - .filterNot { it.chatId.value == chatId && it.topicId == topicId } + - NotifyException( - kind, - org.monogram.core.models.PeerId(chatId), - settings, - topicId - ) - if (!complete) prefs.edit().putBoolean(NOTIFY_EXCEPTIONS_READY, false).apply() + .filterNot { it.chatId.value == chatId && it.topicId == topicId } + NotifyException( + kind, org.monogram.core.models.PeerId(chatId), settings, topicId + ) + if (!complete) prefs.edit { putBoolean(NOTIFY_EXCEPTIONS_READY, false) } } else { val key = when (kind) { "users" -> NOTIFY_USERS @@ -163,7 +236,7 @@ class NotificationLocalStore(context: Context) { "broadcasts" -> NOTIFY_BROADCASTS else -> return } - prefs.edit().putString(key, NotifySettingsCodec.encode(settings)).apply() + prefs.edit { putString(key, NotifySettingsCodec.encode(settings)) } } } @@ -175,7 +248,7 @@ class NotificationLocalStore(context: Context) { fun setFolderMuted(folderId: Int, muted: Boolean) { val next = mutedFolders().toMutableSet() if (muted) next.add(folderId) else next.remove(folderId) - prefs.edit().putStringSet(MUTED_FOLDERS, next.map { it.toString() }.toSet()).apply() + prefs.edit { putStringSet(MUTED_FOLDERS, next.map { it.toString() }.toSet()) } } /** @@ -196,7 +269,7 @@ class NotificationLocalStore(context: Context) { ) } set(value) { - prefs.edit().apply { + prefs.edit { if (value == null) { remove(NOTIFY_USERS) remove(NOTIFY_CHATS) @@ -206,7 +279,7 @@ class NotificationLocalStore(context: Context) { putString(NOTIFY_CHATS, NotifySettingsCodec.encode(value.chats)) putString(NOTIFY_BROADCASTS, NotifySettingsCodec.encode(value.broadcasts)) } - }.apply() + } } /** Cached `account.getNotifyExceptions` rows. `null` means they were never stored. */ @@ -216,7 +289,7 @@ class NotificationLocalStore(context: Context) { return NotifyExceptionCodec.decodeAll(prefs.getString(NOTIFY_EXCEPTIONS, null)) } set(value) { - prefs.edit().apply { + prefs.edit { if (value == null) { remove(NOTIFY_EXCEPTIONS) remove(NOTIFY_EXCEPTIONS_READY) @@ -224,7 +297,7 @@ class NotificationLocalStore(context: Context) { putString(NOTIFY_EXCEPTIONS, NotifyExceptionCodec.encodeAll(value)) putBoolean(NOTIFY_EXCEPTIONS_READY, true) } - }.apply() + } } /** @@ -233,33 +306,39 @@ class NotificationLocalStore(context: Context) { * existing channel's importance (the platform blocks app-side raises), so priority and popup * both select a channel variant through the same decision. */ - fun policy(users: NotifySettings, chats: NotifySettings, broadcasts: NotifySettings, exceptions: Map, folderMutedChatIds: Set): NotificationPolicyState = - NotificationPolicyState( - users = users, - chats = chats, - broadcasts = broadcasts, - exceptions = exceptions, - peerModes = peerModes(), - folderMutedChatIds = folderMutedChatIds, - storiesEnabled = storiesEnabled, - reactionsEnabled = reactionsEnabled, - pinnedEnabled = pinnedEnabled, - contactJoinedEnabled = contactJoinedEnabled, - giftsEnabled = giftsEnabled, - inAppSound = inAppSound, - inAppVibrate = inAppVibrate, - inAppPreview = inAppPreview, - inChatSound = inChatSound, - inAppPriority = inAppPriority, - callsVibrate = callsVibrate, - callsRingtone = callsRingtone, - popupUsers = categoryPopup("users") && categoryPriorityHigh("users"), - popupChats = categoryPopup("chats") && categoryPriorityHigh("chats"), - popupBroadcasts = categoryPopup("broadcasts") && categoryPriorityHigh("broadcasts"), - popupStories = categoryPopup("stories"), - popupReactions = categoryPopup("reactions"), - badgeMuted = badgeMuted, - ) + fun policy( + users: NotifySettings, + chats: NotifySettings, + broadcasts: NotifySettings, + exceptions: Map, + folderMutedChatIds: Set + ): NotificationPolicyState = NotificationPolicyState( + notificationsEnabled = notificationsEnabled, + users = users, + chats = chats, + broadcasts = broadcasts, + exceptions = exceptions, + peerModes = peerModes(), + folderMutedChatIds = folderMutedChatIds, + storiesEnabled = storiesEnabled, + reactionsEnabled = reactionsEnabled, + pinnedEnabled = pinnedEnabled, + contactJoinedEnabled = contactJoinedEnabled, + giftsEnabled = giftsEnabled, + inAppSound = inAppSound, + inAppVibrate = inAppVibrate, + inAppPreview = inAppPreview, + inChatSound = inChatSound, + inAppPriority = inAppPriority, + callsVibrate = callsVibrate, + callsRingtone = callsRingtone, + popupUsers = categoryPopup("users") && categoryPriorityHigh("users"), + popupChats = categoryPopup("chats") && categoryPriorityHigh("chats"), + popupBroadcasts = categoryPopup("broadcasts") && categoryPriorityHigh("broadcasts"), + popupStories = categoryPopup("stories"), + popupReactions = categoryPopup("reactions"), + badgeMuted = badgeMuted, + ) fun secret(): ByteArray { val stored = prefs.getString(SECRET, null) @@ -267,51 +346,58 @@ class NotificationLocalStore(context: Context) { return stored.chunked(2).map { it.toInt(16).toByte() }.toByteArray() } val generated = ByteArray(256).also { java.security.SecureRandom().nextBytes(it) } - prefs.edit().putString(SECRET, generated.joinToString("") { "%02x".format(it) }).apply() + prefs.edit { putString(SECRET, generated.joinToString("") { "%02x".format(it) }) } return generated } var providerMode: PushProviderMode get() = PushProviderMode.fromStored(prefs.getString(PROVIDER_MODE, null)) - set(value) { prefs.edit().putString(PROVIDER_MODE, value.name).apply() } + set(value) { + prefs.edit { putString(PROVIDER_MODE, value.name) } + } var distributorPackage: String get() = prefs.getString(DISTRIBUTOR, "").orEmpty() - set(value) { prefs.edit().putString(DISTRIBUTOR, value).apply() } + set(value) { + prefs.edit { putString(DISTRIBUTOR, value) } + } /** Blank unless the user typed one. Never prefilled with a public gateway. */ var simplePushGateway: String get() = prefs.getString(GATEWAY, "").orEmpty() - set(value) { prefs.edit().putString(GATEWAY, value.trim()).apply() } + set(value) { + prefs.edit { putString(GATEWAY, value.trim()) } + } var pushInstance: String get() = prefs.getString(PUSH_INSTANCE, "").orEmpty() - set(value) { prefs.edit().putString(PUSH_INSTANCE, value).apply() } + set(value) { + prefs.edit { putString(PUSH_INSTANCE, value) } + } /** Recent share-target chats, most recent first. Not cleared when a notification is dismissed. */ var shareChatIds: List - get() = prefs.getString(SHARE_CHATS, "").orEmpty().split(',').mapNotNull { it.toLongOrNull() } - set(value) { prefs.edit().putString(SHARE_CHATS, value.joinToString(",")).apply() } + get() = prefs.getString(SHARE_CHATS, "").orEmpty().split(',') + .mapNotNull { it.toLongOrNull() } + set(value) { + prefs.edit { putString(SHARE_CHATS, value.joinToString(",")) } + } fun token(): String = prefs.getString(TOKEN, "").orEmpty() fun tokenType(): Int = prefs.getInt(TOKEN_TYPE, 0) fun setToken(type: PushTokenType, token: String) { - prefs.edit() - .putInt(TOKEN_TYPE, type.code) - .putString(TOKEN, token) - .apply() + prefs.edit { putInt(TOKEN_TYPE, type.code).putString(TOKEN, token) } } fun setLastRegister(result: String) { - prefs.edit().putString(LAST_REGISTER, result.take(120)).apply() + prefs.edit { putString(LAST_REGISTER, result.take(120)) } } fun recordPayload(payload: PushPayload) { - prefs.edit() - .putString(LAST_LOC, payload.locKey) - .putString(LAST_IDS, payload.customIds) - .putLong(LAST_MESSAGE_AT, System.currentTimeMillis()) - .apply() + prefs.edit { + putString(LAST_LOC, payload.locKey).putString(LAST_IDS, payload.customIds) + .putLong(LAST_MESSAGE_AT, System.currentTimeMillis()) + } } fun debugState(gms: Boolean, distributor: String, permission: Boolean): PushDebugState { @@ -335,21 +421,27 @@ class NotificationLocalStore(context: Context) { } fun clearPushIdentity(clearSecret: Boolean = false) { - prefs.edit() - .remove(TOKEN) - .remove(TOKEN_TYPE) - .remove(LAST_REGISTER) - .remove(LAST_LOC) - .remove(LAST_IDS) - .apply { - if (clearSecret) remove(SECRET) - } - .apply() + prefs.edit { + remove(TOKEN).remove(TOKEN_TYPE).remove(LAST_REGISTER).remove(LAST_LOC) + .remove(LAST_IDS).apply { + if (clearSecret) remove(SECRET) + } + } } companion object { + fun clearAccount(context: Context, accountUserId: Long) { + check( + context.applicationContext.getSharedPreferences( + "${PREFS}_$accountUserId", + Context.MODE_PRIVATE, + ).edit().clear().commit() + ) { "Unable to clear account notification preferences" } + } + private const val NOTIFY_EXCEPTIONS_READY = "notify_exceptions_ready" private const val PREFS = "monogram_notifications" + private const val NOTIFICATIONS_ENABLED = "notifications_enabled" private const val INAPP_SOUND = "inapp_sound" private const val INAPP_VIBRATE = "inapp_vibrate" private const val INAPP_PREVIEW = "inapp_preview" @@ -403,6 +495,7 @@ interface PushRegistration { fun setAccountUserId(userId: Long) {} fun onVisibleChat(chatId: Long?, topicId: Int? = null) {} fun onChatRead(chatId: Long) {} + /** Drops the server device token and UnifiedPush registration while the session is still alive. */ suspend fun unregisterPush() {} fun onLogout() {} diff --git a/core/common/src/main/java/org/monogram/core/common/push/NotificationPolicy.kt b/core/common/src/main/java/org/monogram/core/common/push/NotificationPolicy.kt index 68544c75b..071593549 100644 --- a/core/common/src/main/java/org/monogram/core/common/push/NotificationPolicy.kt +++ b/core/common/src/main/java/org/monogram/core/common/push/NotificationPolicy.kt @@ -7,6 +7,7 @@ import org.monogram.core.models.PeerId import org.monogram.core.models.contains data class NotificationPolicyState( + val notificationsEnabled: Boolean = true, val users: NotifySettings = NotifySettings(), val chats: NotifySettings = NotifySettings(), val broadcasts: NotifySettings = NotifySettings(), @@ -56,11 +57,14 @@ fun decideNotification( openTopicId: Int? = null, ): NotificationDecision { val kind = when (payload.channelKind) { - PushChannelKind.Private, PushChannelKind.Group, PushChannelKind.Channel, PushChannelKind.Other -> - state.peerKinds[payload.chatId] ?: payload.channelKind + PushChannelKind.Private, PushChannelKind.Group, PushChannelKind.Channel, PushChannelKind.Other -> state.peerKinds[payload.chatId] + ?: payload.channelKind else -> payload.channelKind } + if (!state.notificationsEnabled) { + return NotificationDecision(false, false, false, false, false, false, kind) + } if (payload.action != PushAction.Show) { return NotificationDecision(false, false, false, false, false, false, kind) } @@ -109,13 +113,14 @@ fun decideNotification( val parentPreview = if (parentSettings.previewInherited) categorySettings.showPreviews else parentSettings.showPreviews val settingsPreview = if (settings.previewInherited) parentPreview else settings.showPreviews - val preview = state.showPreview && settingsPreview && - (!appInForeground || state.inAppPreview) && (mode?.preview ?: true) + val preview = + state.showPreview && settingsPreview && (!appInForeground || state.inAppPreview) && (mode?.preview + ?: true) val calls = kind == PushChannelKind.Calls - val sound = !payload.silent && !settings.silent && (mode?.sound ?: true) && - (!appInForeground || state.inAppSound) && !(calls && state.callsRingtone == "none") - val vibrate = !payload.silent && (!appInForeground || state.inAppVibrate) && - !(calls && state.callsVibrate == "off") + val sound = !payload.silent && !settings.silent && (mode?.sound + ?: true) && (!appInForeground || state.inAppSound) && !(calls && state.callsRingtone == "none") + val vibrate = + !payload.silent && (!appInForeground || state.inAppVibrate) && !(calls && state.callsVibrate == "off") // A chat mode overrides the category; silently posted messages and a foreground app without // in-app priority never pop up. val categoryPopup = when (kind) { @@ -127,7 +132,8 @@ fun decideNotification( PushChannelKind.Calls -> true PushChannelKind.Other -> state.popupUsers } - val popup = !payload.silent && (mode?.popup ?: (categoryPopup && (!appInForeground || state.inAppPriority))) + val popup = !payload.silent && (mode?.popup + ?: (categoryPopup && (!appInForeground || state.inAppPriority))) // "Include muted chats in the badge counter": a muted chat that still posts (a mention, or a // showed notification) contributes nothing when the setting is off. val muted = settings.isMuted(nowSeconds) || mode?.isMuted(nowSeconds) == true diff --git a/core/common/src/main/java/org/monogram/core/common/push/PushPayload.kt b/core/common/src/main/java/org/monogram/core/common/push/PushPayload.kt index 565d43474..8042bef72 100644 --- a/core/common/src/main/java/org/monogram/core/common/push/PushPayload.kt +++ b/core/common/src/main/java/org/monogram/core/common/push/PushPayload.kt @@ -110,6 +110,7 @@ fun liveMessagePayload( senderId: Long?, title: String, kind: PushChannelKind, + topicId: Int? = null, ): PushPayload? { if (outgoing || chatId == 0L || messageId <= 0) return null val locKey = when (kind) { @@ -136,6 +137,7 @@ fun liveMessagePayload( userId = null, action = PushAction.Show, channelKind = kind, + topicId = topicId?.takeIf { it > 0 }, senderId = senderId, ) } diff --git a/core/common/src/test/java/org/monogram/core/common/push/NotificationPopupPolicyTest.kt b/core/common/src/test/java/org/monogram/core/common/push/NotificationPopupPolicyTest.kt index 9fa022d9b..dde2af465 100644 --- a/core/common/src/test/java/org/monogram/core/common/push/NotificationPopupPolicyTest.kt +++ b/core/common/src/test/java/org/monogram/core/common/push/NotificationPopupPolicyTest.kt @@ -11,6 +11,23 @@ import org.junit.Test * `popup = false` to a channel that does not peek, while the group summary stays silent. */ class NotificationPopupPolicyTest { + @Test + fun accountNotificationSwitchSuppressesAllPresentation() { + val payload = parsePushPayload( + """{"loc_key":"MESSAGE_TEXT","loc_args":["Ada","hi"],"custom":{"from_id":7,"msg_id":1}}""" + ) + val decision = decideNotification( + payload, + NotificationPolicyState(notificationsEnabled = false), + nowSeconds = 10, + appInForeground = false, + ) + assertFalse(decision.show) + assertFalse(decision.badge) + assertFalse(decision.sound) + assertFalse(decision.popup) + } + private fun payload(locKey: String = "MESSAGE_TEXT", custom: String = "\"from_id\":7,\"msg_id\":1") = parsePushPayload("""{"loc_key":"$locKey","loc_args":["Ada","hi"],"custom":{$custom}}""") diff --git a/core/common/src/test/java/org/monogram/core/common/push/PushPayloadTest.kt b/core/common/src/test/java/org/monogram/core/common/push/PushPayloadTest.kt index b4575acde..343488df2 100644 --- a/core/common/src/test/java/org/monogram/core/common/push/PushPayloadTest.kt +++ b/core/common/src/test/java/org/monogram/core/common/push/PushPayloadTest.kt @@ -9,6 +9,18 @@ import org.monogram.core.models.PeerId import org.monogram.core.models.PushTokenType class PushPayloadTest { + @Test + fun liveMessageRetainsValidForumTopic() { + val payload = liveMessagePayload( + -5L, 10, false, "hello", null, "Ada", 42L, "Group", PushChannelKind.Group, topicId = 7, + )!! + assertEquals(7, payload.topicId) + val withoutTopic = liveMessagePayload( + -5L, 10, false, "hello", null, "Ada", 42L, "Group", PushChannelKind.Group, topicId = 0, + )!! + assertEquals(null, withoutTopic.topicId) + } + @Test fun liveGroupMessageKeepsSenderAndConversationSeparate() { val payload = liveMessagePayload( diff --git a/core/database/schemas/org.monogram.core.database.MonogramDatabase/11.json b/core/database/schemas/org.monogram.core.database.MonogramDatabase/11.json new file mode 100644 index 000000000..f15dc69bc --- /dev/null +++ b/core/database/schemas/org.monogram.core.database.MonogramDatabase/11.json @@ -0,0 +1,1929 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "c689f800ecaba6e70f1b1e69be8fd105", + "entities": [ + { + "tableName": "meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "chats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `isChannel` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `isForum` INTEGER NOT NULL, `left` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `lastMessagePreview` TEXT, `lastMessageOutgoing` INTEGER NOT NULL, `lastMessageSenderName` TEXT, `lastMessageMediaKind` TEXT, `lastMessageDate` INTEGER, `photoCacheKey` TEXT, `archived` INTEGER NOT NULL, `muted` INTEGER NOT NULL, `muteOverride` INTEGER NOT NULL, `unreadMark` INTEGER NOT NULL, `unreadMentionsCount` INTEGER NOT NULL, `unreadReactionsCount` INTEGER NOT NULL, `isContact` INTEGER NOT NULL, `isBot` INTEGER NOT NULL, `isVerified` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `pinnedOrder` INTEGER NOT NULL, `readInboxMaxId` INTEGER NOT NULL, `readOutboxMaxId` INTEGER NOT NULL, `peerStatus` TEXT, `peerStatusAt` INTEGER, `lastMediaThumbCacheKey` TEXT, `lastMessageId` INTEGER NOT NULL, `dialogScrollMessageId` INTEGER, `canView` INTEGER NOT NULL, `canSendPlain` INTEGER NOT NULL, `canSendPhotos` INTEGER NOT NULL, `canSendPolls` INTEGER NOT NULL DEFAULT 0, `canForward` INTEGER NOT NULL, `canDeleteOthers` INTEGER NOT NULL, `canManageTopics` INTEGER NOT NULL, `emojiStatusDocumentId` INTEGER, `photoKnown` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isChannel", + "columnName": "isChannel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGroup", + "columnName": "isGroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForum", + "columnName": "isForum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "left", + "columnName": "left", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessagePreview", + "columnName": "lastMessagePreview", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMessageOutgoing", + "columnName": "lastMessageOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageSenderName", + "columnName": "lastMessageSenderName", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMessageMediaKind", + "columnName": "lastMessageMediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMessageDate", + "columnName": "lastMessageDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "photoCacheKey", + "columnName": "photoCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "archived", + "columnName": "archived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "muteOverride", + "columnName": "muteOverride", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMark", + "columnName": "unreadMark", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMentionsCount", + "columnName": "unreadMentionsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadReactionsCount", + "columnName": "unreadReactionsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isContact", + "columnName": "isContact", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBot", + "columnName": "isBot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVerified", + "columnName": "isVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinnedOrder", + "columnName": "pinnedOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readInboxMaxId", + "columnName": "readInboxMaxId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readOutboxMaxId", + "columnName": "readOutboxMaxId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "peerStatus", + "columnName": "peerStatus", + "affinity": "TEXT" + }, + { + "fieldPath": "peerStatusAt", + "columnName": "peerStatusAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastMediaThumbCacheKey", + "columnName": "lastMediaThumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dialogScrollMessageId", + "columnName": "dialogScrollMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "canView", + "columnName": "canView", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canSendPlain", + "columnName": "canSendPlain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canSendPhotos", + "columnName": "canSendPhotos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canSendPolls", + "columnName": "canSendPolls", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canForward", + "columnName": "canForward", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDeleteOthers", + "columnName": "canDeleteOthers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManageTopics", + "columnName": "canManageTopics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojiStatusDocumentId", + "columnName": "emojiStatusDocumentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "photoKnown", + "columnName": "photoKnown", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chats_archived_left_pinned_pinnedOrder_lastMessageDate_id", + "unique": false, + "columnNames": [ + "archived", + "left", + "pinned", + "pinnedOrder", + "lastMessageDate", + "id" + ], + "orders": [ + "ASC", + "ASC", + "DESC", + "ASC", + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_archived_left_pinned_pinnedOrder_lastMessageDate_id` ON `${TABLE_NAME}` (`archived` ASC, `left` ASC, `pinned` DESC, `pinnedOrder` ASC, `lastMessageDate` DESC, `id` DESC)" + } + ] + }, + { + "tableName": "folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `emoticon` TEXT NOT NULL, `chatIdsCsv` TEXT NOT NULL, `pinnedChatIdsCsv` TEXT NOT NULL, `includeContacts` INTEGER NOT NULL, `includeNonContacts` INTEGER NOT NULL, `includeGroups` INTEGER NOT NULL, `includeChannels` INTEGER NOT NULL, `includeBots` INTEGER NOT NULL, `excludeChatIdsCsv` TEXT NOT NULL, `excludeMuted` INTEGER NOT NULL, `excludeRead` INTEGER NOT NULL, `excludeArchived` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoticon", + "columnName": "emoticon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdsCsv", + "columnName": "chatIdsCsv", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pinnedChatIdsCsv", + "columnName": "pinnedChatIdsCsv", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "includeContacts", + "columnName": "includeContacts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeNonContacts", + "columnName": "includeNonContacts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeGroups", + "columnName": "includeGroups", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeChannels", + "columnName": "includeChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeBots", + "columnName": "includeBots", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "excludeChatIdsCsv", + "columnName": "excludeChatIdsCsv", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "excludeMuted", + "columnName": "excludeMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "excludeRead", + "columnName": "excludeRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "excludeArchived", + "columnName": "excludeArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "outgoing_media_batches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`batchId` INTEGER NOT NULL, `chatId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `silent` INTEGER NOT NULL, `scheduleDate` INTEGER, `album` INTEGER NOT NULL, `phase` TEXT NOT NULL, PRIMARY KEY(`batchId`))", + "fields": [ + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "silent", + "columnName": "silent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduleDate", + "columnName": "scheduleDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "batchId" + ] + } + }, + { + "tableName": "outgoing_media_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`randomId` INTEGER NOT NULL, `batchId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `source_name` TEXT NOT NULL, `source_size` INTEGER NOT NULL, `source_sha256` TEXT NOT NULL, `message_chatId` INTEGER NOT NULL, `message_id` INTEGER NOT NULL, `message_senderId` INTEGER, `message_text` TEXT, `message_date` INTEGER NOT NULL, `message_editDate` INTEGER, `message_outgoing` INTEGER NOT NULL, `message_mediaCacheKey` TEXT, `message_mediaKind` TEXT, `message_thumbCacheKey` TEXT, `message_mediaDuration` INTEGER, `message_mediaWidth` INTEGER, `message_mediaHeight` INTEGER, `message_groupedId` INTEGER, `message_fileName` TEXT, `message_fileSize` INTEGER, `message_supportsStreaming` INTEGER NOT NULL, `message_reactionsJson` TEXT, `message_repliesCount` INTEGER NOT NULL, `message_discussionPeerId` INTEGER, `message_replyQuote` TEXT, `message_replyQuoteEntitiesJson` TEXT, `message_entitiesJson` TEXT, `message_replyToMsgId` INTEGER, `message_replyToTopId` INTEGER, `message_forumTopic` INTEGER NOT NULL, `message_fwdFrom` TEXT, `message_fwdFromId` INTEGER, `message_fwdDate` INTEGER, `message_viaBot` TEXT, `message_senderName` TEXT, `message_replyMarkupJson` TEXT, `message_pending` INTEGER NOT NULL, `message_failed` INTEGER NOT NULL DEFAULT 0, `message_randomId` INTEGER, `message_checklistJson` TEXT, PRIMARY KEY(`randomId`), FOREIGN KEY(`batchId`) REFERENCES `outgoing_media_batches`(`batchId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "randomId", + "columnName": "randomId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source.name", + "columnName": "source_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source.size", + "columnName": "source_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source.sha256", + "columnName": "source_sha256", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message.chatId", + "columnName": "message_chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.id", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.senderId", + "columnName": "message_senderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.text", + "columnName": "message_text", + "affinity": "TEXT" + }, + { + "fieldPath": "message.date", + "columnName": "message_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.editDate", + "columnName": "message_editDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.outgoing", + "columnName": "message_outgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.mediaCacheKey", + "columnName": "message_mediaCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaKind", + "columnName": "message_mediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "message.thumbCacheKey", + "columnName": "message_thumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaDuration", + "columnName": "message_mediaDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaWidth", + "columnName": "message_mediaWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaHeight", + "columnName": "message_mediaHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.groupedId", + "columnName": "message_groupedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fileName", + "columnName": "message_fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fileSize", + "columnName": "message_fileSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.supportsStreaming", + "columnName": "message_supportsStreaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.reactionsJson", + "columnName": "message_reactionsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.repliesCount", + "columnName": "message_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.discussionPeerId", + "columnName": "message_discussionPeerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyQuote", + "columnName": "message_replyQuote", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyQuoteEntitiesJson", + "columnName": "message_replyQuoteEntitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.entitiesJson", + "columnName": "message_entitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyToMsgId", + "columnName": "message_replyToMsgId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyToTopId", + "columnName": "message_replyToTopId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.forumTopic", + "columnName": "message_forumTopic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.fwdFrom", + "columnName": "message_fwdFrom", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fwdFromId", + "columnName": "message_fwdFromId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fwdDate", + "columnName": "message_fwdDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.viaBot", + "columnName": "message_viaBot", + "affinity": "TEXT" + }, + { + "fieldPath": "message.senderName", + "columnName": "message_senderName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyMarkupJson", + "columnName": "message_replyMarkupJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.pending", + "columnName": "message_pending", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.failed", + "columnName": "message_failed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "message.randomId", + "columnName": "message_randomId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.checklistJson", + "columnName": "message_checklistJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "randomId" + ] + }, + "indices": [ + { + "name": "index_outgoing_media_items_batchId_position", + "unique": true, + "columnNames": [ + "batchId", + "position" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_outgoing_media_items_batchId_position` ON `${TABLE_NAME}` (`batchId`, `position`)" + }, + { + "name": "index_outgoing_media_items_source_name", + "unique": true, + "columnNames": [ + "source_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_outgoing_media_items_source_name` ON `${TABLE_NAME}` (`source_name`)" + } + ], + "foreignKeys": [ + { + "table": "outgoing_media_batches", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "batchId" + ], + "referencedColumns": [ + "batchId" + ] + } + ] + }, + { + "tableName": "outgoing_text_sends", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`randomId` INTEGER NOT NULL, `silent` INTEGER NOT NULL, `noWebpage` INTEGER NOT NULL, `scheduleDate` INTEGER, `webpageUrl` TEXT, `phase` TEXT NOT NULL, `message_chatId` INTEGER NOT NULL, `message_id` INTEGER NOT NULL, `message_senderId` INTEGER, `message_text` TEXT, `message_date` INTEGER NOT NULL, `message_editDate` INTEGER, `message_outgoing` INTEGER NOT NULL, `message_mediaCacheKey` TEXT, `message_mediaKind` TEXT, `message_thumbCacheKey` TEXT, `message_mediaDuration` INTEGER, `message_mediaWidth` INTEGER, `message_mediaHeight` INTEGER, `message_groupedId` INTEGER, `message_fileName` TEXT, `message_fileSize` INTEGER, `message_supportsStreaming` INTEGER NOT NULL, `message_reactionsJson` TEXT, `message_repliesCount` INTEGER NOT NULL, `message_discussionPeerId` INTEGER, `message_replyQuote` TEXT, `message_replyQuoteEntitiesJson` TEXT, `message_entitiesJson` TEXT, `message_replyToMsgId` INTEGER, `message_replyToTopId` INTEGER, `message_forumTopic` INTEGER NOT NULL, `message_fwdFrom` TEXT, `message_fwdFromId` INTEGER, `message_fwdDate` INTEGER, `message_viaBot` TEXT, `message_senderName` TEXT, `message_replyMarkupJson` TEXT, `message_pending` INTEGER NOT NULL, `message_failed` INTEGER NOT NULL DEFAULT 0, `message_randomId` INTEGER, `message_checklistJson` TEXT, PRIMARY KEY(`randomId`))", + "fields": [ + { + "fieldPath": "randomId", + "columnName": "randomId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "silent", + "columnName": "silent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noWebpage", + "columnName": "noWebpage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduleDate", + "columnName": "scheduleDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "webpageUrl", + "columnName": "webpageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message.chatId", + "columnName": "message_chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.id", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.senderId", + "columnName": "message_senderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.text", + "columnName": "message_text", + "affinity": "TEXT" + }, + { + "fieldPath": "message.date", + "columnName": "message_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.editDate", + "columnName": "message_editDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.outgoing", + "columnName": "message_outgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.mediaCacheKey", + "columnName": "message_mediaCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaKind", + "columnName": "message_mediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "message.thumbCacheKey", + "columnName": "message_thumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaDuration", + "columnName": "message_mediaDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaWidth", + "columnName": "message_mediaWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaHeight", + "columnName": "message_mediaHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.groupedId", + "columnName": "message_groupedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fileName", + "columnName": "message_fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fileSize", + "columnName": "message_fileSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.supportsStreaming", + "columnName": "message_supportsStreaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.reactionsJson", + "columnName": "message_reactionsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.repliesCount", + "columnName": "message_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.discussionPeerId", + "columnName": "message_discussionPeerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyQuote", + "columnName": "message_replyQuote", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyQuoteEntitiesJson", + "columnName": "message_replyQuoteEntitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.entitiesJson", + "columnName": "message_entitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyToMsgId", + "columnName": "message_replyToMsgId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyToTopId", + "columnName": "message_replyToTopId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.forumTopic", + "columnName": "message_forumTopic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.fwdFrom", + "columnName": "message_fwdFrom", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fwdFromId", + "columnName": "message_fwdFromId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fwdDate", + "columnName": "message_fwdDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.viaBot", + "columnName": "message_viaBot", + "affinity": "TEXT" + }, + { + "fieldPath": "message.senderName", + "columnName": "message_senderName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyMarkupJson", + "columnName": "message_replyMarkupJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.pending", + "columnName": "message_pending", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.failed", + "columnName": "message_failed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "message.randomId", + "columnName": "message_randomId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.checklistJson", + "columnName": "message_checklistJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "randomId" + ] + } + }, + { + "tableName": "scheduled_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hasUnsupportedContent` INTEGER NOT NULL DEFAULT 1, `chatId` INTEGER NOT NULL, `id` INTEGER NOT NULL, `senderId` INTEGER, `text` TEXT, `date` INTEGER NOT NULL, `editDate` INTEGER, `outgoing` INTEGER NOT NULL, `mediaCacheKey` TEXT, `mediaKind` TEXT, `thumbCacheKey` TEXT, `mediaDuration` INTEGER, `mediaWidth` INTEGER, `mediaHeight` INTEGER, `groupedId` INTEGER, `fileName` TEXT, `fileSize` INTEGER, `supportsStreaming` INTEGER NOT NULL, `reactionsJson` TEXT, `repliesCount` INTEGER NOT NULL, `discussionPeerId` INTEGER, `replyQuote` TEXT, `replyQuoteEntitiesJson` TEXT, `entitiesJson` TEXT, `replyToMsgId` INTEGER, `replyToTopId` INTEGER, `forumTopic` INTEGER NOT NULL, `fwdFrom` TEXT, `fwdFromId` INTEGER, `fwdDate` INTEGER, `viaBot` TEXT, `senderName` TEXT, `replyMarkupJson` TEXT, `pending` INTEGER NOT NULL, `failed` INTEGER NOT NULL DEFAULT 0, `randomId` INTEGER, `checklistJson` TEXT, PRIMARY KEY(`chatId`, `id`))", + "fields": [ + { + "fieldPath": "hasUnsupportedContent", + "columnName": "hasUnsupportedContent", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "message.chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.senderId", + "columnName": "senderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "message.date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.editDate", + "columnName": "editDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.outgoing", + "columnName": "outgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.mediaCacheKey", + "columnName": "mediaCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "message.thumbCacheKey", + "columnName": "thumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaDuration", + "columnName": "mediaDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaWidth", + "columnName": "mediaWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaHeight", + "columnName": "mediaHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.groupedId", + "columnName": "groupedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fileSize", + "columnName": "fileSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.supportsStreaming", + "columnName": "supportsStreaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.reactionsJson", + "columnName": "reactionsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.discussionPeerId", + "columnName": "discussionPeerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyQuote", + "columnName": "replyQuote", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyQuoteEntitiesJson", + "columnName": "replyQuoteEntitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.entitiesJson", + "columnName": "entitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyToMsgId", + "columnName": "replyToMsgId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyToTopId", + "columnName": "replyToTopId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.forumTopic", + "columnName": "forumTopic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.fwdFrom", + "columnName": "fwdFrom", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fwdFromId", + "columnName": "fwdFromId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fwdDate", + "columnName": "fwdDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.viaBot", + "columnName": "viaBot", + "affinity": "TEXT" + }, + { + "fieldPath": "message.senderName", + "columnName": "senderName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyMarkupJson", + "columnName": "replyMarkupJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.pending", + "columnName": "pending", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.failed", + "columnName": "failed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "message.randomId", + "columnName": "randomId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.checklistJson", + "columnName": "checklistJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId", + "id" + ] + }, + "indices": [ + { + "name": "index_scheduled_messages_chatId_date", + "unique": false, + "columnNames": [ + "chatId", + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scheduled_messages_chatId_date` ON `${TABLE_NAME}` (`chatId`, `date`)" + } + ] + }, + { + "tableName": "scheduled_queue_revisions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `revision` INTEGER NOT NULL, PRIMARY KEY(`chatId`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId" + ] + } + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `id` INTEGER NOT NULL, `senderId` INTEGER, `text` TEXT, `date` INTEGER NOT NULL, `editDate` INTEGER, `outgoing` INTEGER NOT NULL, `mediaCacheKey` TEXT, `mediaKind` TEXT, `thumbCacheKey` TEXT, `mediaDuration` INTEGER, `mediaWidth` INTEGER, `mediaHeight` INTEGER, `groupedId` INTEGER, `fileName` TEXT, `fileSize` INTEGER, `supportsStreaming` INTEGER NOT NULL, `reactionsJson` TEXT, `repliesCount` INTEGER NOT NULL, `discussionPeerId` INTEGER, `replyQuote` TEXT, `replyQuoteEntitiesJson` TEXT, `entitiesJson` TEXT, `replyToMsgId` INTEGER, `replyToTopId` INTEGER, `forumTopic` INTEGER NOT NULL, `fwdFrom` TEXT, `fwdFromId` INTEGER, `fwdDate` INTEGER, `viaBot` TEXT, `senderName` TEXT, `replyMarkupJson` TEXT, `pending` INTEGER NOT NULL, `failed` INTEGER NOT NULL DEFAULT 0, `randomId` INTEGER, `checklistJson` TEXT, PRIMARY KEY(`chatId`, `id`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderId", + "columnName": "senderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editDate", + "columnName": "editDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "outgoing", + "columnName": "outgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaCacheKey", + "columnName": "mediaCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbCacheKey", + "columnName": "thumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaDuration", + "columnName": "mediaDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "mediaWidth", + "columnName": "mediaWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "mediaHeight", + "columnName": "mediaHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "groupedId", + "columnName": "groupedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportsStreaming", + "columnName": "supportsStreaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactionsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discussionPeerId", + "columnName": "discussionPeerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "replyQuote", + "columnName": "replyQuote", + "affinity": "TEXT" + }, + { + "fieldPath": "replyQuoteEntitiesJson", + "columnName": "replyQuoteEntitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "entitiesJson", + "columnName": "entitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMsgId", + "columnName": "replyToMsgId", + "affinity": "INTEGER" + }, + { + "fieldPath": "replyToTopId", + "columnName": "replyToTopId", + "affinity": "INTEGER" + }, + { + "fieldPath": "forumTopic", + "columnName": "forumTopic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fwdFrom", + "columnName": "fwdFrom", + "affinity": "TEXT" + }, + { + "fieldPath": "fwdFromId", + "columnName": "fwdFromId", + "affinity": "INTEGER" + }, + { + "fieldPath": "fwdDate", + "columnName": "fwdDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "viaBot", + "columnName": "viaBot", + "affinity": "TEXT" + }, + { + "fieldPath": "senderName", + "columnName": "senderName", + "affinity": "TEXT" + }, + { + "fieldPath": "replyMarkupJson", + "columnName": "replyMarkupJson", + "affinity": "TEXT" + }, + { + "fieldPath": "pending", + "columnName": "pending", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failed", + "columnName": "failed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "randomId", + "columnName": "randomId", + "affinity": "INTEGER" + }, + { + "fieldPath": "checklistJson", + "columnName": "checklistJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId", + "id" + ] + }, + "indices": [ + { + "name": "index_messages_chatId_date", + "unique": false, + "columnNames": [ + "chatId", + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_date` ON `${TABLE_NAME}` (`chatId`, `date`)" + } + ] + }, + { + "tableName": "message_tombstones", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`chatId`, `id`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId", + "id" + ] + } + }, + { + "tableName": "message_holes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `startId` INTEGER NOT NULL, `endId` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`chatId`, `startId`, `endId`, `source`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startId", + "columnName": "startId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endId", + "columnName": "endId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId", + "startId", + "endId", + "source" + ] + } + }, + { + "tableName": "update_cursors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `pts` INTEGER NOT NULL, `qts` INTEGER NOT NULL, `date` INTEGER NOT NULL, `seq` INTEGER NOT NULL, `channelPts` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pts", + "columnName": "pts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "qts", + "columnName": "qts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seq", + "columnName": "seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelPts", + "columnName": "channelPts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "update_batch_receipts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountGeneration` INTEGER NOT NULL, `batchId` INTEGER NOT NULL, `appliedAtMs` INTEGER NOT NULL, PRIMARY KEY(`accountGeneration`, `batchId`))", + "fields": [ + { + "fieldPath": "accountGeneration", + "columnName": "accountGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appliedAtMs", + "columnName": "appliedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountGeneration", + "batchId" + ] + } + }, + { + "tableName": "peers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `username` TEXT, `about` TEXT, `avatarCacheKey` TEXT, `status` TEXT, `statusAt` INTEGER, `emojiStatusDocumentId` INTEGER, `extraJson` TEXT, `isSelf` INTEGER NOT NULL, `avatarKnown` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarCacheKey", + "columnName": "avatarCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "statusAt", + "columnName": "statusAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "emojiStatusDocumentId", + "columnName": "emojiStatusDocumentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "extraJson", + "columnName": "extraJson", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelf", + "columnName": "isSelf", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "avatarKnown", + "columnName": "avatarKnown", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "profile_tabs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`peerId` INTEGER NOT NULL, `tabsJson` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`peerId`))", + "fields": [ + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabsJson", + "columnName": "tabsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "peerId" + ] + } + }, + { + "tableName": "profile_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`peerId` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `title` TEXT NOT NULL, `username` TEXT, `avatarCacheKey` TEXT, `status` TEXT, `statusAt` INTEGER, `role` TEXT NOT NULL, `rank` TEXT, `totalCount` INTEGER NOT NULL, PRIMARY KEY(`peerId`, `userId`))", + "fields": [ + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarCacheKey", + "columnName": "avatarCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "statusAt", + "columnName": "statusAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rank", + "columnName": "rank", + "affinity": "TEXT" + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "peerId", + "userId" + ] + } + }, + { + "tableName": "profile_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`peerId` INTEGER NOT NULL, `tab` TEXT NOT NULL, `messageId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `chatId` INTEGER NOT NULL, `text` TEXT, `date` INTEGER NOT NULL, `outgoing` INTEGER NOT NULL, `mediaCacheKey` TEXT, `mediaKind` TEXT, `thumbCacheKey` TEXT, `fileName` TEXT, `fileSize` INTEGER, PRIMARY KEY(`peerId`, `tab`, `messageId`))", + "fields": [ + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tab", + "columnName": "tab", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outgoing", + "columnName": "outgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaCacheKey", + "columnName": "mediaCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbCacheKey", + "columnName": "thumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "peerId", + "tab", + "messageId" + ] + } + }, + { + "tableName": "profile_common", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`peerId` INTEGER NOT NULL, `chatId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `title` TEXT NOT NULL, `isChannel` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `photoCacheKey` TEXT, PRIMARY KEY(`peerId`, `chatId`))", + "fields": [ + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isChannel", + "columnName": "isChannel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGroup", + "columnName": "isGroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photoCacheKey", + "columnName": "photoCacheKey", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "peerId", + "chatId" + ] + } + }, + { + "tableName": "sponsors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `sourceChannelId` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceChannelId", + "columnName": "sourceChannelId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c689f800ecaba6e70f1b1e69be8fd105')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/androidTest/java/org/monogram/core/database/AccountCacheIsolationTest.kt b/core/database/src/androidTest/java/org/monogram/core/database/AccountCacheIsolationTest.kt new file mode 100644 index 000000000..75d8d29d7 --- /dev/null +++ b/core/database/src/androidTest/java/org/monogram/core/database/AccountCacheIsolationTest.kt @@ -0,0 +1,177 @@ +package org.monogram.core.database + +import android.database.sqlite.SQLiteDatabase +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.monogram.core.database.entity.MetaEntity +import org.monogram.core.models.AuthSession +import org.monogram.core.models.Chat +import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.PeerId +import org.monogram.core.models.Profile +import java.io.File +import java.util.UUID + +class AccountCacheIsolationTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val firstId = UUID.randomUUID().mostSignificantBits.and(Long.MAX_VALUE).coerceAtLeast(2) + private val secondId = firstId xor 1L + private val peer = PeerId(7) + private lateinit var first: OfflineWarmup + private lateinit var second: OfflineWarmup + + @Before + fun open() { + first = OfflineWarmup(DatabaseProvider.get(context, firstId)) + second = OfflineWarmup(DatabaseProvider.get(context, secondId)) + } + + @After + fun cleanup() { + for (id in listOf(firstId, secondId)) { + DatabaseProvider.close(id) + context.deleteDatabase("monogram-account-$id.db") + File(context.filesDir, "session-identity-$id.json").delete() + } + } + + private suspend fun seed(id: Long, cache: OfflineWarmup, label: String) { + cache.upsertChats(listOf(Chat(peer, "chat-$label"))) + val message = Message( + MessageId(peer, 41), + peer, + "message-$label", + System.currentTimeMillis() / 1000, + outgoing = false + ) + cache.replaceMessages(peer, listOf(message)) + cache.recentHistory.remember(peer.value, listOf(message)) + cache.setDraft(peer, text = "draft-$label") + val database = DatabaseProvider.get(context, id) + SessionMetadataStore(database).upsertProfile(Profile(peer, "user", "user-$label")) + database.metaDao().upsert(MetaEntity("update_cursor.primary", label)) + } + + private suspend fun assertCached(id: Long, cache: OfflineWarmup, label: String) { + assertEquals("chat-$label", cache.chat(peer)?.title) + assertEquals("message-$label", cache.messages(peer).single().text) + assertEquals("draft-$label", cache.draft(peer)) + val database = DatabaseProvider.get(context, id) + assertEquals("user-$label", SessionMetadataStore(database).readProfile(peer.value)?.title) + assertEquals(label, database.metaDao().get("update_cursor.primary")?.value) + } + + @Test + fun concurrentAccountLookupsPublishOneDatabasePerAccount() = runBlocking { + DatabaseProvider.close(firstId) + DatabaseProvider.close(secondId) + val start = CompletableDeferred() + val lookups = (0 until 16).map { index -> + async(Dispatchers.IO) { + start.await() + val id = if (index % 2 == 0) firstId else secondId + id to DatabaseProvider.get(context, id) + } + } + start.complete(Unit) + val results = lookups.awaitAll() + for ((id, database) in results) { + assertSame(DatabaseProvider.get(context, id), database) + } + assertNotSame(results[0].second, results[1].second) + } + + @Test + fun identicalIdsRemainIsolatedInMemoryAndAfterReopening() = runBlocking { + assertNotSame( + DatabaseProvider.get(context, firstId), + DatabaseProvider.get(context, secondId) + ) + assertNotSame(first.recentHistory, second.recentHistory) + seed(firstId, first, "first") + seed(secondId, second, "second") + assertCached(firstId, first, "first") + assertCached(secondId, second, "second") + assertEquals("message-first", first.recentHistory.peek(peer.value)?.single()?.text) + assertEquals("message-second", second.recentHistory.peek(peer.value)?.single()?.text) + + DatabaseProvider.close(firstId) + DatabaseProvider.close(secondId) + first = OfflineWarmup(DatabaseProvider.get(context, firstId)) + second = OfflineWarmup(DatabaseProvider.get(context, secondId)) + assertCached(firstId, first, "first") + assertCached(secondId, second, "second") + } + + @Test + fun staleIdentityBackupCannotReplaceExistingAuthorization() = runBlocking { + var store = SessionMetadataStore(DatabaseProvider.get(context, firstId)) + store.saveAuthorized(AuthSession(PeerId(firstId), 2)) + DatabaseProvider.close(firstId) + val backup = File(context.filesDir, "session-identity-$firstId.json") + val stale = """{"session.user_id":"42","session.dc_id":"4","session.authorized":"1"}""" + backup.writeText(stale) + + store = SessionMetadataStore(DatabaseProvider.get(context, firstId)) + assertEquals(firstId, store.readAuthorizedUserId()?.value) + assertEquals("2", store.readMeta(SessionMetadataStore.KEY_DC_ID)) + store.clearAuthorized() + DatabaseProvider.close(firstId) + backup.writeText(stale) + store = SessionMetadataStore(DatabaseProvider.get(context, firstId)) + assertEquals(false, store.isAuthorized()) + assertEquals(null, store.readAuthorizedUserId()) + + store.saveAuthorized(AuthSession(PeerId(firstId), 2)) + store.clearSession() + DatabaseProvider.close(firstId) + backup.writeText(stale) + store = SessionMetadataStore(DatabaseProvider.get(context, firstId)) + assertEquals(false, store.isAuthorized()) + assertEquals(null, store.readAuthorizedUserId()) + } + + @Test + fun unsupportedFutureSchemaDoesNotDeleteAccountData() { + DatabaseProvider.close(firstId) + val path = context.getDatabasePath("monogram-account-$firstId.db") + SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READWRITE) + .use { database -> + database.execSQL("CREATE TABLE preserve_marker (value TEXT NOT NULL)") + database.execSQL("INSERT INTO preserve_marker VALUES ('pending')") + database.version = DatabaseProvider.SCHEMA_VERSION + 1 + } + + assertNotNull(runCatching { DatabaseProvider.get(context, firstId) }.exceptionOrNull()) + SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { database -> + database.rawQuery("SELECT value FROM preserve_marker", null).use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("pending", cursor.getString(0)) + } + } + } + + @Test + fun clearingOneAccountLeavesOtherAccountCachesIntact() = runBlocking { + seed(firstId, first, "first") + seed(secondId, second, "second") + first.clearAccountCache() + assertEquals(emptyList(), first.chats()) + assertEquals(emptyList(), first.messages(peer)) + assertEquals(null, first.recentHistory.peek(peer.value)) + assertCached(secondId, second, "second") + assertEquals("message-second", second.recentHistory.peek(peer.value)?.single()?.text) + } +} diff --git a/core/database/src/androidTest/java/org/monogram/core/database/HistoryInvalidationTest.kt b/core/database/src/androidTest/java/org/monogram/core/database/HistoryInvalidationTest.kt new file mode 100644 index 000000000..3cec84b71 --- /dev/null +++ b/core/database/src/androidTest/java/org/monogram/core/database/HistoryInvalidationTest.kt @@ -0,0 +1,127 @@ +package org.monogram.core.database + +import android.content.Context +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.database.entity.MessageEntity +import org.monogram.core.database.entity.MessageHoleEntity +import org.monogram.core.database.entity.MetaEntity +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class HistoryInvalidationTest { + private lateinit var context: Context + private lateinit var name: String + private lateinit var database: MonogramDatabase + + @Before + fun open() { + context = InstrumentationRegistry.getInstrumentation().targetContext + name = "history-invalidation-${UUID.randomUUID()}.db" + database = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + } + + @After + fun close() { + database.close() + context.deleteDatabase(name) + } + + @Test + fun invalidationIsScopedToTheAffectedChat() = runBlocking { + database.messageDao().upsertAll( + listOf( + MessageEntity( + 7, + 41, + null, + "stale", + 100, + outgoing = false, + mediaCacheKey = null, + mediaKind = null + ), + MessageEntity( + 8, + 42, + null, + "unaffected", + 100, + outgoing = false, + mediaCacheKey = null, + mediaKind = null + ), + ), + ) + database.messageHoleDao().insert(MessageHoleEntity(7, 1, 20)) + database.messageHoleDao().insert(MessageHoleEntity(8, 1, 20)) + database.metaDao().upsert(MetaEntity("history_coverage:7", "1")) + database.metaDao().upsert(MetaEntity("history_coverage:8", "1")) + + OfflineWarmup(database).invalidateCachedHistory(7) + + assertEquals(emptyList(), database.messageDao().latestForChat(7, 10).map { it.id }) + assertEquals(listOf(42), database.messageDao().latestForChat(8, 10).map { it.id }) + assertEquals(null, database.metaDao().get("history_coverage:7")) + assertEquals("1", database.metaDao().get("history_coverage:8")?.value) + assertEquals( + emptyList(), + database.messageHoleDao().forChat(7, MessageHoleEntity.SOURCE_HISTORY) + ) + assertEquals(1, database.messageHoleDao().forChat(8, MessageHoleEntity.SOURCE_HISTORY).size) + } + + @Test + fun invalidationDropsConfirmedRowsAndCoverageButKeepsPendingSends() = runBlocking { + val messages = database.messageDao() + messages.upsertAll( + listOf( + MessageEntity( + 7, + 41, + null, + "cached", + 100, + outgoing = false, + mediaCacheKey = null, + mediaKind = null + ), + MessageEntity( + 7, + -9, + 7, + "pending", + 101, + outgoing = true, + mediaCacheKey = null, + mediaKind = null, + pending = true, + randomId = 90 + ), + ), + ) + database.messageHoleDao().insert(MessageHoleEntity(7, 1, 20)) + database.metaDao().upsert(MetaEntity("history_coverage:7", "1")) + database.metaDao().upsert(MetaEntity("update_cursor.primary", "1")) + database.metaDao().upsert(MetaEntity("historyXcoverage:7", "keep")) + + OfflineWarmup(database).invalidateCachedHistories() + + assertEquals(listOf(-9), messages.pendingForChat(7).map { it.id }) + assertEquals(emptyList(), messages.latestForChat(7, 10).map { it.id }) + assertEquals( + emptyList(), + database.messageHoleDao().forChat(7, MessageHoleEntity.SOURCE_HISTORY) + ) + assertEquals(null, database.metaDao().get("history_coverage:7")) + assertEquals("1", database.metaDao().get("update_cursor.primary")?.value) + assertEquals("keep", database.metaDao().get("historyXcoverage:7")?.value) + } +} diff --git a/core/database/src/androidTest/java/org/monogram/core/database/ScheduledQueuePersistenceTest.kt b/core/database/src/androidTest/java/org/monogram/core/database/ScheduledQueuePersistenceTest.kt index 056b1b80f..247b7d7c7 100644 --- a/core/database/src/androidTest/java/org/monogram/core/database/ScheduledQueuePersistenceTest.kt +++ b/core/database/src/androidTest/java/org/monogram/core/database/ScheduledQueuePersistenceTest.kt @@ -7,8 +7,8 @@ import androidx.room.Room import androidx.room.withTransaction import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import org.json.JSONObject import org.junit.After import org.junit.Assert.* @@ -34,8 +34,11 @@ class ScheduledQueuePersistenceTest { } private fun reopen() { - database = Room.databaseBuilder(context, MonogramDatabase::class.java, name) - .addMigrations(DatabaseProvider.MIGRATION_9_10).build() + database = + Room + .databaseBuilder(context, MonogramDatabase::class.java, name) + .addMigrations(DatabaseProvider.MIGRATION_9_10, DatabaseProvider.MIGRATION_10_11) + .build() } @After @@ -44,162 +47,278 @@ class ScheduledQueuePersistenceTest { context.deleteDatabase(name) } - private fun message(id: Int = 37, text: String = "scheduled", chatId: Long = 7) = MessageEntity( - chatId = chatId, id = id, senderId = 7, text = text, date = 100, - outgoing = true, mediaCacheKey = "attachment", mediaKind = "photo", + private fun message( + id: Int = 37, + text: String = "scheduled", + chatId: Long = 7, + ) = MessageEntity( + chatId = chatId, + id = id, + senderId = 7, + text = text, + date = 100, + outgoing = true, + mediaCacheKey = "attachment", + mediaKind = "photo", entitiesJson = """[{"kind":"bold","offset":0,"length":3}]""", - replyToMsgId = 42, replyToTopId = 99, forumTopic = true, + replyToMsgId = 42, + replyToTopId = 99, + forumTopic = true, ) @Test - fun overlappingIdsRemainSeparateAndSurviveRestart() = runBlocking { - val scheduled = ScheduledMessageEntity(message()) - database.metaDao().upsert(MetaEntity("fixture.identity", "7")) - database.messageDao().upsertAll(listOf(message(text = "history"))) - val dao = database.scheduledMessageDao() - assertTrue(dao.replaceIfCurrent(7, dao.revision(7), listOf(scheduled))) - database.close() - reopen() - assertEquals(scheduled, database.scheduledMessageDao().forChat(7).single()) - assertEquals("history", database.messageDao().byIds(7, listOf(37)).single().text) - assertEquals("7", database.metaDao().get("fixture.identity")?.value) - database.scheduledMessageDao().remove(7, listOf(37)) - assertTrue(database.scheduledMessageDao().forChat(7).isEmpty()) - assertEquals("history", database.messageDao().byIds(7, listOf(37)).single().text) - } + fun overlappingIdsRemainSeparateAndSurviveRestart() = + runBlocking { + val scheduled = ScheduledMessageEntity(message()) + database.metaDao().upsert(MetaEntity("fixture.identity", "7")) + database.messageDao().upsertAll(listOf(message(text = "history"))) + val dao = database.scheduledMessageDao() + assertTrue(dao.replaceIfCurrent(7, dao.revision(7), listOf(scheduled))) + database.close() + reopen() + assertEquals(scheduled, database.scheduledMessageDao().forChat(7).single()) + assertEquals( + "history", + database + .messageDao() + .byIds(7, listOf(37)) + .single() + .text, + ) + assertEquals("7", database.metaDao().get("fixture.identity")?.value) + database.scheduledMessageDao().remove(7, listOf(37)) + assertTrue(database.scheduledMessageDao().forChat(7).isEmpty()) + assertEquals( + "history", + database + .messageDao() + .byIds(7, listOf(37)) + .single() + .text, + ) + } @Test - fun newerEventsDeletionsAndAccountClearingInvalidateInFlightSnapshots() = runBlocking { - val dao = database.scheduledMessageDao() - val beforeEvent = dao.revision(7) - dao.applyMessages(7, listOf(ScheduledMessageEntity(message(text = "new event")))) - assertFalse(dao.replaceIfCurrent(7, beforeEvent, listOf(ScheduledMessageEntity(message())))) - val beforeDelete = dao.revision(7) - dao.remove(7, listOf(37)) - assertFalse( - dao.replaceIfCurrent( - 7, - beforeDelete, - listOf(ScheduledMessageEntity(message())) + fun newerEventsDeletionsAndAccountClearingInvalidateInFlightSnapshots() = + runBlocking { + val dao = database.scheduledMessageDao() + val beforeEvent = dao.revision(7) + dao.applyMessages(7, listOf(ScheduledMessageEntity(message(text = "new event")))) + assertFalse( + dao.replaceIfCurrent( + 7, + beforeEvent, + listOf(ScheduledMessageEntity(message())) + ) ) - ) - val beforeClear = dao.revision(8) - OfflineWarmup(database).clearAccountCache() - assertFalse( - dao.replaceIfCurrent( - 8, - beforeClear, - listOf(ScheduledMessageEntity(message(chatId = 8))) + val beforeDelete = dao.revision(7) + dao.remove(7, listOf(37)) + assertFalse( + dao.replaceIfCurrent( + 7, + beforeDelete, + listOf(ScheduledMessageEntity(message())), + ), ) - ) - assertTrue(dao.forChat(7).isEmpty()) - assertTrue(dao.forChat(8).isEmpty()) - } + val beforeClear = dao.revision(8) + OfflineWarmup(database).clearAccountCache() + assertFalse( + dao.replaceIfCurrent( + 8, + beforeClear, + listOf(ScheduledMessageEntity(message(chatId = 8))), + ), + ) + assertTrue(dao.forChat(7).isEmpty()) + assertTrue(dao.forChat(8).isEmpty()) + } @Test - fun eventRowsAndRevisionRollbackTogetherAndRejectForeignOrPendingIds() = runBlocking { - val dao = database.scheduledMessageDao() - val revision = dao.revision(7) - try { - database.withTransaction { - dao.applyMessages(7, listOf(ScheduledMessageEntity(message()))) - error("synthetic rollback") - } - } catch (_: IllegalStateException) { - } - assertEquals(revision, dao.revision(7)) - assertTrue(dao.forChat(7).isEmpty()) - for (invalid in listOf( - message(id = -1), - message(chatId = 8), - message().copy(pending = true) - )) { + fun eventRowsAndRevisionRollbackTogetherAndRejectForeignOrPendingIds() = + runBlocking { + val dao = database.scheduledMessageDao() + val revision = dao.revision(7) try { - dao.applyMessages(7, listOf(ScheduledMessageEntity(invalid))) - fail("invalid queue identity accepted") - } catch (_: IllegalArgumentException) { + database.withTransaction { + dao.applyMessages(7, listOf(ScheduledMessageEntity(message()))) + error("synthetic rollback") + } + } catch (_: IllegalStateException) { + } + assertEquals(revision, dao.revision(7)) + assertTrue(dao.forChat(7).isEmpty()) + for (invalid in listOf( + message(id = -1), + message(chatId = 8), + message().copy(pending = true), + )) { + try { + dao.applyMessages(7, listOf(ScheduledMessageEntity(invalid))) + fail("invalid queue identity accepted") + } catch (_: IllegalArgumentException) { + } } + assertEquals(revision, dao.revision(7)) } - assertEquals(revision, dao.revision(7)) - } @Test - fun durableQueueEventsAndReceiptsCommitTogetherWithoutTouchingHistory() = runBlocking { - val warmup = OfflineWarmup(database) - val dao = database.scheduledMessageDao() - database.messageDao().upsertAll(listOf(message(text = "history"))) - val scheduled = org.monogram.core.models.ScheduledMessage(message().toModel()) - assertTrue(warmup.applyDurableBatch(5, 8, null, emptyMap()) { - warmup.applyScheduledMessage(scheduled) - }) - assertTrue( - warmup.applyDurableBatch( - 5, - 8, - null, - emptyMap() - ) { error("replay must not apply twice") }) - val revision = dao.revision(7) - try { - warmup.applyDurableBatch(5, 9, null, emptyMap()) { - warmup.deleteScheduledMessages(org.monogram.core.models.PeerId(7), listOf(37)) - error("synthetic receipt rollback") + fun durableQueueEventsAndReceiptsCommitTogetherWithoutTouchingHistory() = + runBlocking { + val warmup = OfflineWarmup(database) + val dao = database.scheduledMessageDao() + database.messageDao().upsertAll(listOf(message(text = "history"))) + val scheduled = + org.monogram.core.models + .ScheduledMessage(message().toModel()) + assertTrue( + warmup.applyDurableBatch(5, 8, null, emptyMap()) { + warmup.applyScheduledMessage(scheduled) + }, + ) + assertTrue( + warmup.applyDurableBatch( + 5, + 8, + null, + emptyMap(), + ) { error("replay must not apply twice") }, + ) + val revision = dao.revision(7) + try { + warmup.applyDurableBatch(5, 9, null, emptyMap()) { + warmup.deleteScheduledMessages( + org.monogram.core.models + .PeerId(7), + listOf(37), + ) + error("synthetic receipt rollback") + } + } catch (_: IllegalStateException) { } - } catch (_: IllegalStateException) { + assertEquals(revision, dao.revision(7)) + assertEquals( + 37, + dao + .forChat(7) + .single() + .message.id, + ) + assertTrue( + warmup.applyDurableBatch(5, 9, null, emptyMap()) { + warmup.deleteScheduledMessages( + org.monogram.core.models + .PeerId(7), + listOf(37), + ) + }, + ) + assertTrue(dao.forChat(7).isEmpty()) + assertEquals( + "history", + database + .messageDao() + .byIds(7, listOf(37)) + .single() + .text, + ) } - assertEquals(revision, dao.revision(7)) - assertEquals(37, dao.forChat(7).single().message.id) - assertTrue(warmup.applyDurableBatch(5, 9, null, emptyMap()) { - warmup.deleteScheduledMessages(org.monogram.core.models.PeerId(7), listOf(37)) - }) - assertTrue(dao.forChat(7).isEmpty()) - assertEquals("history", database.messageDao().byIds(7, listOf(37)).single().text) - } @Test - fun authoritativeOutgoingModesReconcileByRandomIdAcrossOverlappingNamespaces() = runBlocking { - val warmup = OfflineWarmup(database) - database.messageDao().upsertAll( - listOf( - message(text = "old history"), - message(id = -37, text = "pending").copy(pending = true, randomId = 123), + fun authoritativeOutgoingModesReconcileByRandomIdAcrossOverlappingNamespaces() = + runBlocking { + val warmup = OfflineWarmup(database) + database.messageDao().upsertAll( + listOf( + message(text = "old history"), + message(id = -37, text = "pending").copy(pending = true, randomId = 123), + ), ) - ) - val queued = org.monogram.core.models.OutgoingMessageResult.Scheduled( - org.monogram.core.models.ScheduledMessage(message(text = "queued").toModel()), - ) - warmup.applyDurableBatch(5, 10, null, emptyMap()) { - warmup.applyOutgoingResult( - 456, - queued + val queued = + org.monogram.core.models.OutgoingMessageResult.Scheduled( + org.monogram.core.models + .ScheduledMessage(message(text = "queued").toModel()), + ) + warmup.applyDurableBatch(5, 10, null, emptyMap()) { + warmup.applyOutgoingResult( + 456, + queued, + ) + } + assertEquals( + 456L, + database + .scheduledMessageDao() + .forChat(7) + .single() + .message.randomId, + ) + assertEquals( + "old history", + database + .messageDao() + .byIds(7, listOf(37)) + .single() + .text, + ) + assertEquals( + -37, + database + .messageDao() + .pendingByRandomId(7, 123) + .single() + .id, + ) + val sent = + org.monogram.core.models.OutgoingMessageResult + .Sent(message(text = "delivered").toModel()) + warmup.applyDurableBatch(5, 11, null, emptyMap()) { + warmup.applyOutgoingResult( + 123, + sent + ) + } + database.close() + reopen() + assertTrue(database.messageDao().pendingByRandomId(7, 123).isEmpty()) + assertEquals( + "delivered", + database + .messageDao() + .byIds(7, listOf(37)) + .single() + .text, + ) + assertEquals( + "queued", + database + .scheduledMessageDao() + .forChat(7) + .single() + .message.text, + ) + assertTrue( + OfflineWarmup(database).applyDurableBatch( + 5, + 11, + null, + emptyMap(), + ) { error("receipt replay") }, ) } - assertEquals(456L, database.scheduledMessageDao().forChat(7).single().message.randomId) - assertEquals("old history", database.messageDao().byIds(7, listOf(37)).single().text) - assertEquals(-37, database.messageDao().pendingByRandomId(7, 123).single().id) - val sent = - org.monogram.core.models.OutgoingMessageResult.Sent(message(text = "delivered").toModel()) - warmup.applyDurableBatch(5, 11, null, emptyMap()) { warmup.applyOutgoingResult(123, sent) } - database.close() - reopen() - assertTrue(database.messageDao().pendingByRandomId(7, 123).isEmpty()) - assertEquals("delivered", database.messageDao().byIds(7, listOf(37)).single().text) - assertEquals("queued", database.scheduledMessageDao().forChat(7).single().message.text) - assertTrue( - OfflineWarmup(database).applyDurableBatch( - 5, - 11, - null, - emptyMap() - ) { error("receipt replay") }) - } - private fun legacyDatabase(version: Int, populate: (SQLiteDatabase) -> Unit) { + private fun legacyDatabase( + version: Int, + populate: (SQLiteDatabase) -> Unit, + ) { database.close() - val schema = InstrumentationRegistry.getInstrumentation().context.assets - .open("org.monogram.core.database.MonogramDatabase/$version.json").bufferedReader() - .use { JSONObject(it.readText()) } - .getJSONObject("database") + val schema = + InstrumentationRegistry + .getInstrumentation() + .context.assets + .open("org.monogram.core.database.MonogramDatabase/$version.json") + .bufferedReader() + .use { JSONObject(it.readText()) } + .getJSONObject("database") val file = context.getDatabasePath(name) file.parentFile!!.mkdirs() SQLiteDatabase.openOrCreateDatabase(file, null).use { old -> @@ -209,11 +328,17 @@ class ScheduledQueuePersistenceTest { val table = entity.getString("tableName") old.execSQL(entity.getString("createSql").replace("\${TABLE_NAME}", table)) val indices = entity.optJSONArray("indices") - if (indices != null) for (j in 0 until indices.length()) old.execSQL( - indices.getJSONObject( - j - ).getString("createSql").replace("\${TABLE_NAME}", table) - ) + if (indices != null) { + for (j in 0 until indices.length()) { + old.execSQL( + indices + .getJSONObject( + j, + ).getString("createSql") + .replace("\${TABLE_NAME}", table), + ) + } + } } val setup = schema.getJSONArray("setupQueries") for (i in 0 until setup.length()) old.execSQL(setup.getString(i)) @@ -224,76 +349,126 @@ class ScheduledQueuePersistenceTest { } @Test - fun unsupportedFlagSurvivesCacheReopenAndSnapshotReplacement() = runBlocking { - val peer = org.monogram.core.models.PeerId(7) - val known = org.monogram.core.models.ScheduledMessage( - message().toModel(), - hasUnsupportedContent = false - ) - OfflineWarmup(database).applyScheduledMessage(known) - database.close(); reopen() - val warmup = OfflineWarmup(database) - assertFalse(warmup.observeScheduledMessages(peer).first().single().hasUnsupportedContent) - val epoch = warmup.outgoingAccountEpoch() - val revision = warmup.scheduledQueueRevision(peer, epoch) - assertTrue( - warmup.replaceScheduledMessagesIfCurrent( - peer, revision, - listOf(known.copy(hasUnsupportedContent = true)), epoch + fun unsupportedFlagSurvivesCacheReopenAndSnapshotReplacement() = + runBlocking { + val peer = + org.monogram.core.models + .PeerId(7) + val known = + org.monogram.core.models.ScheduledMessage( + message().toModel(), + hasUnsupportedContent = false, + ) + OfflineWarmup(database).applyScheduledMessage(known) + database.close() + reopen() + val warmup = OfflineWarmup(database) + assertFalse( + warmup + .observeScheduledMessages(peer) + .first() + .single() + .hasUnsupportedContent, ) - ) - assertTrue(warmup.observeScheduledMessages(peer).first().single().hasUnsupportedContent) - } - - @Test - fun versionNineMigrationPreservesIdentityHistoryAndPendingRandomIds() = runBlocking { - legacyDatabase(9) { old -> - old.execSQL( - "INSERT INTO meta (`key`, value) VALUES (?, ?)", - arrayOf(SessionMetadataStore.KEY_USER_ID, "7") + val epoch = warmup.outgoingAccountEpoch() + val revision = warmup.scheduledQueueRevision(peer, epoch) + assertTrue( + warmup.replaceScheduledMessagesIfCurrent( + peer, + revision, + listOf(known.copy(hasUnsupportedContent = true)), + epoch, + ), ) - old.execSQL( - "INSERT INTO meta (`key`, value) VALUES (?, ?)", - arrayOf(SessionMetadataStore.KEY_AUTHORIZED, "1") + assertTrue( + warmup + .observeScheduledMessages(peer) + .first() + .single() + .hasUnsupportedContent, ) - val schema = InstrumentationRegistry.getInstrumentation().context.assets - .open("org.monogram.core.database.MonogramDatabase/9.json").bufferedReader() - .use { JSONObject(it.readText()) }.getJSONObject("database") - .getJSONArray("entities") - val chat = (0 until schema.length()).map { schema.getJSONObject(it) } - .first { it.getString("tableName") == "chats" } - val fields = chat.getJSONArray("fields") - val values = ContentValues() - for (i in 0 until fields.length()) { - val field = fields.getJSONObject(i) - if (field.optBoolean("notNull", false)) { - val column = field.getString("columnName") - if (field.getString("affinity") == "INTEGER") values.put(column, 0) - else values.put(column, "") + } + + @Test + fun versionNineMigrationPreservesIdentityHistoryAndPendingRandomIds() = + runBlocking { + legacyDatabase(9) { old -> + old.execSQL( + "INSERT INTO meta (`key`, value) VALUES (?, ?)", + arrayOf(SessionMetadataStore.KEY_USER_ID, "7"), + ) + old.execSQL( + "INSERT INTO meta (`key`, value) VALUES (?, ?)", + arrayOf(SessionMetadataStore.KEY_AUTHORIZED, "1"), + ) + val schema = + InstrumentationRegistry + .getInstrumentation() + .context.assets + .open("org.monogram.core.database.MonogramDatabase/9.json") + .bufferedReader() + .use { JSONObject(it.readText()) } + .getJSONObject("database") + .getJSONArray("entities") + val chat = + (0 until schema.length()) + .map { schema.getJSONObject(it) } + .first { it.getString("tableName") == "chats" } + val fields = chat.getJSONArray("fields") + val values = ContentValues() + for (i in 0 until fields.length()) { + val field = fields.getJSONObject(i) + if (field.optBoolean("notNull", false)) { + val column = field.getString("columnName") + if (field.getString("affinity") == "INTEGER") { + values.put(column, 0) + } else { + values.put(column, "") + } + } } + values.put("id", 7L) + values.put("title", "Fixture") + values.put("canSendPlain", 1) + old.insertOrThrow("chats", null, values) + old.insertOrThrow( + "messages", + null, + ContentValues().apply { + put("chatId", 7L) + put("id", -37) + put("text", "pending fixture") + put("date", 100L) + put("outgoing", 1) + put("supportsStreaming", 0) + put("repliesCount", 0) + put("forumTopic", 0) + put("pending", 1) + put("randomId", 123456789L) + }, + ) } - values.put("id", 7L) - values.put("title", "Fixture") - values.put("canSendPlain", 1) - old.insertOrThrow("chats", null, values) - old.insertOrThrow("messages", null, ContentValues().apply { - put("chatId", 7L); put("id", -37); put("text", "pending fixture"); put("date", 100L) - put("outgoing", 1); put("supportsStreaming", 0); put("repliesCount", 0) - put("forumTopic", 0); put("pending", 1); put("randomId", 123456789L) - }) + assertEquals("7", database.metaDao().get(SessionMetadataStore.KEY_USER_ID)?.value) + assertEquals("1", database.metaDao().get(SessionMetadataStore.KEY_AUTHORIZED)?.value) + assertFalse(database.chatDao().get(7)!!.canSendPolls) + assertTrue(database.chatDao().get(7)!!.canSendPlain) + assertTrue(database.outgoingTextSendDao().all().isEmpty()) + assertTrue(database.outgoingMediaSendDao().all().isEmpty()) + val pending = database.messageDao().byIds(7, listOf(-37)).single() + assertTrue(pending.pending) + assertEquals(123456789L, pending.randomId) + assertTrue(database.scheduledMessageDao().forChat(7).isEmpty()) + val migratedQueue = database.scheduledMessageDao() + assertTrue( + migratedQueue.replaceIfCurrent( + 7, + 0, + listOf(ScheduledMessageEntity(message())) + ) + ) + assertEquals( + DatabaseProvider.SCHEMA_VERSION, + database.openHelper.readableDatabase.version + ) } - assertEquals("7", database.metaDao().get(SessionMetadataStore.KEY_USER_ID)?.value) - assertEquals("1", database.metaDao().get(SessionMetadataStore.KEY_AUTHORIZED)?.value) - assertFalse(database.chatDao().get(7)!!.canSendPolls) - assertTrue(database.chatDao().get(7)!!.canSendPlain) - assertTrue(database.outgoingTextSendDao().all().isEmpty()) - assertTrue(database.outgoingMediaSendDao().all().isEmpty()) - val pending = database.messageDao().byIds(7, listOf(-37)).single() - assertTrue(pending.pending) - assertEquals(123456789L, pending.randomId) - assertTrue(database.scheduledMessageDao().forChat(7).isEmpty()) - val migratedQueue = database.scheduledMessageDao() - assertTrue(migratedQueue.replaceIfCurrent(7, 0, listOf(ScheduledMessageEntity(message())))) - assertEquals(DatabaseProvider.SCHEMA_VERSION, database.openHelper.readableDatabase.version) - } } diff --git a/core/database/src/androidTest/java/org/monogram/core/database/SessionMetadataStoreTest.kt b/core/database/src/androidTest/java/org/monogram/core/database/SessionMetadataStoreTest.kt new file mode 100644 index 000000000..543afc99b --- /dev/null +++ b/core/database/src/androidTest/java/org/monogram/core/database/SessionMetadataStoreTest.kt @@ -0,0 +1,62 @@ +package org.monogram.core.database + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.models.AuthSession +import org.monogram.core.models.PeerId + +@RunWith(AndroidJUnit4::class) +class SessionMetadataStoreTest { + @Test + fun profileRecoveryNeverOverridesSignOutOrExistingIdentity() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, MonogramDatabase::class.java).build() + try { + val store = SessionMetadataStore(database) + val old = AuthSession(PeerId(11), 2) + val newer = AuthSession(PeerId(22), 4) + assertTrue(store.recoverAuthorizedIfAbsent(old)) + assertEquals(false, store.recoverAuthorizedIfAbsent(newer)) + assertEquals(11L, store.readAuthorizedUserId()?.value) + store.clearSession() + assertEquals(false, store.recoverAuthorizedIfAbsent(old)) + assertEquals(null, store.readAuthorizedUserId()) + assertEquals("0", store.readMeta(SessionMetadataStore.KEY_AUTHORIZED)) + store.saveAuthorized(newer) + assertEquals(22L, store.readAuthorizedUserId()?.value) + } finally { + database.close() + } + } + + @Test + fun authorizationChangesRollBackTogetherOnFailure() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, MonogramDatabase::class.java).build() + try { + val store = SessionMetadataStore(database) + store.saveAuthorized(AuthSession(PeerId(11), 2)) + val sql = database.openHelper.writableDatabase + sql.execSQL("CREATE TRIGGER reject_authorized BEFORE INSERT ON meta WHEN NEW.`key` = 'session.authorized' BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END") + assertTrue(runCatching { store.saveAuthorized(AuthSession(PeerId(22), 4)) }.isFailure) + assertEquals(11L, store.readAuthorizedUserId()?.value) + assertEquals("2", store.readMeta(SessionMetadataStore.KEY_DC_ID)) + assertTrue(store.isAuthorized()) + sql.execSQL("DROP TRIGGER reject_authorized") + + sql.execSQL("CREATE TRIGGER reject_dc BEFORE INSERT ON meta WHEN NEW.`key` = 'session.dc_id' BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END") + assertTrue(runCatching { store.clearAuthorized() }.isFailure) + assertEquals(11L, store.readAuthorizedUserId()?.value) + assertEquals("2", store.readMeta(SessionMetadataStore.KEY_DC_ID)) + assertTrue(store.isAuthorized()) + } finally { + database.close() + } + } +} diff --git a/core/database/src/main/java/org/monogram/core/database/DatabaseProvider.kt b/core/database/src/main/java/org/monogram/core/database/DatabaseProvider.kt index 466281b42..a1fad216c 100644 --- a/core/database/src/main/java/org/monogram/core/database/DatabaseProvider.kt +++ b/core/database/src/main/java/org/monogram/core/database/DatabaseProvider.kt @@ -19,10 +19,11 @@ import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext object DatabaseProvider { - @Volatile - private var instance: MonogramDatabase? = null + private const val DEFAULT_INSTANCE = "default" - const val SCHEMA_VERSION = 10 + private val instances = mutableMapOf() + + const val SCHEMA_VERSION = 11 val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { @@ -70,9 +71,7 @@ object DatabaseProvider { val MIGRATION_7_8 = object : Migration(7, 8) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( - "CREATE TABLE IF NOT EXISTS update_batch_receipts (" + - "accountGeneration INTEGER NOT NULL, batchId INTEGER NOT NULL, " + - "appliedAtMs INTEGER NOT NULL, PRIMARY KEY(accountGeneration, batchId))" + "CREATE TABLE IF NOT EXISTS update_batch_receipts (" + "accountGeneration INTEGER NOT NULL, batchId INTEGER NOT NULL, " + "appliedAtMs INTEGER NOT NULL, PRIMARY KEY(accountGeneration, batchId))" ) } } @@ -83,6 +82,13 @@ object DatabaseProvider { } } + val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE chats ADD COLUMN photoKnown INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE peers ADD COLUMN avatarKnown INTEGER NOT NULL DEFAULT 0") + } + } + val MIGRATION_9_10 = object : Migration(9, 10) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( @@ -254,68 +260,115 @@ object DatabaseProvider { fun hasMigrationPath(fromVersion: Int, toVersion: Int = SCHEMA_VERSION): Boolean = fromVersion <= 0 || fromVersion in 1..toVersion - fun get(context: Context): MonogramDatabase { - instance?.let { return it } - return synchronized(this) { - instance ?: open(context.applicationContext) - } + fun get(context: Context): MonogramDatabase = getNamed( + context = context, + instanceKey = DEFAULT_INSTANCE, + databaseName = DB_NAME, + identityBackupName = IDENTITY_BACKUP, + ) + + /** Opens an isolated database for one Telegram account. */ + fun get(context: Context, accountId: Long): MonogramDatabase { + require(accountId > 0) { "accountId must be positive" } + val suffix = accountId.toString() + return getNamed( + context = context, + instanceKey = "account:$suffix", + databaseName = "monogram-account-$suffix.db", + identityBackupName = "session-identity-$suffix.json", + ) + } + + private fun getNamed( + context: Context, + instanceKey: String, + databaseName: String, + identityBackupName: String, + ): MonogramDatabase = synchronized(this) { + instances[instanceKey] ?: open( + context.applicationContext, + databaseName, + identityBackupName, + ).also { instances[instanceKey] = it } } fun closeAndReset() { synchronized(this) { - instance?.close() - instance = null + instances.values.forEach { it.close() } + instances.clear() + } + } + + fun close(accountId: Long) { + synchronized(this) { + instances.remove("account:$accountId")?.close() } } - private fun open(app: Context): MonogramDatabase { - snapshotIdentity(app) - return try { - build(app).also { finishOpen(app, it) } - } catch (_: IllegalStateException) { - instance?.close() - instance = null - app.deleteDatabase(DB_NAME) - build(app).also { finishOpen(app, it) } + fun closeDefault() { + synchronized(this) { + instances.remove(DEFAULT_INSTANCE)?.close() + } + } + + private fun open( + app: Context, + databaseName: String, + identityBackupName: String, + ): MonogramDatabase { + snapshotIdentity(app, databaseName, identityBackupName) + val database = build(app, databaseName) + try { + finishOpen(app, database, identityBackupName) + return database + } catch (failure: Throwable) { + try { + database.close() + } catch (closeFailure: Throwable) { + failure.addSuppressed(closeFailure) + } + throw failure } } - private fun finishOpen(app: Context, db: MonogramDatabase) { + private fun finishOpen( + app: Context, + db: MonogramDatabase, + identityBackupName: String, + ) { db.openHelper.writableDatabase - restoreIdentity(app, db) - instance = db + restoreIdentity(app, db, identityBackupName) } - private fun build(app: Context): MonogramDatabase = - Room.databaseBuilder(app, MonogramDatabase::class.java, DB_NAME) - .addMigrations( - MIGRATION_1_2, - MIGRATION_2_3, - MIGRATION_3_4, - MIGRATION_4_5, - MIGRATION_5_6, - MIGRATION_6_7, - MIGRATION_7_8, - MIGRATION_8_9, - MIGRATION_9_10, - ) - .setJournalMode(androidx.room.RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) - .setQueryExecutor(roomQueryExecutor) - .setTransactionExecutor(roomTransactionExecutor) + private fun build(app: Context, databaseName: String): MonogramDatabase = + Room.databaseBuilder(app, MonogramDatabase::class.java, databaseName).addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + MIGRATION_7_8, + MIGRATION_8_9, + MIGRATION_9_10, + MIGRATION_10_11, + ).setJournalMode(androidx.room.RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) + .setQueryExecutor(roomQueryExecutor).setTransactionExecutor(roomTransactionExecutor) .addCallback(object : androidx.room.RoomDatabase.Callback() { override fun onOpen(db: SupportSQLiteDatabase) { db.execSQL("PRAGMA synchronous=NORMAL") } - }) - .fallbackToDestructiveMigration(dropAllTables = true) - .fallbackToDestructiveMigrationOnDowngrade(dropAllTables = true) - .build() + }).build() /** Cache tables may be dropped; keep only login identity (not messages/chats). */ - internal fun snapshotIdentity(context: Context) { - val backup = identityBackupFile(context) + internal fun snapshotIdentity( + context: Context, + databaseName: String = DB_NAME, + identityBackupName: String = IDENTITY_BACKUP, + ) { + val backup = identityBackupFile(context, identityBackupName) if (backup.exists()) return - val file = context.getDatabasePath(DB_NAME) + val file = context.getDatabasePath(databaseName) if (!file.exists()) return val sqlite = runCatching { SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY) @@ -334,37 +387,53 @@ object DatabaseProvider { backup.writeText(json.toString()) } } catch (_: Exception) { - // Missing/corrupt cache: still allow Room to recreate. + // Let Room validate the database even if the identity snapshot is unavailable. } finally { sqlite.close() } } - internal fun restoreIdentity(context: Context, db: MonogramDatabase) { - val backup = identityBackupFile(context) + internal fun restoreIdentity( + context: Context, + db: MonogramDatabase, + identityBackupName: String = IDENTITY_BACKUP, + ) { + val backup = identityBackupFile(context, identityBackupName) if (!backup.exists()) return val json = runCatching { JSONObject(backup.readText()) }.getOrNull() ?: run { backup.delete() return } val sqlite = db.openHelper.writableDatabase - identityKeys.forEach { key -> - if (!json.has(key)) return@forEach - val value = json.optString(key, "") - sqlite.execSQL( - "INSERT OR REPLACE INTO meta (`key`, value) VALUES (?, ?)", - arrayOf(key, value), - ) + sqlite.beginTransaction() + try { + val identityExists = sqlite.query( + "SELECT 1 FROM meta WHERE `key` IN (?, ?, ?) LIMIT 1", + identityKeys.toTypedArray(), + ).use { it.moveToFirst() } + if (!identityExists) { + identityKeys.forEach { key -> + if (!json.has(key)) return@forEach + val value = json.optString(key, "") + sqlite.execSQL( + "INSERT INTO meta (`key`, value) VALUES (?, ?)", + arrayOf(key, value), + ) + } + } + sqlite.setTransactionSuccessful() + } finally { + sqlite.endTransaction() } backup.delete() } - private fun identityBackupFile(context: Context): File = - File(context.filesDir, IDENTITY_BACKUP) + private fun identityBackupFile(context: Context, name: String): File = + File(context.filesDir, name) } -internal class RoomLane(val kind: Kind) : - AbstractCoroutineContextElement(Key), ThreadContextElement { +internal class RoomLane(val kind: Kind) : AbstractCoroutineContextElement(Key), + ThreadContextElement { enum class Kind { FOREGROUND, MAINTENANCE } override fun updateThreadContext(context: CoroutineContext): Kind? { diff --git a/core/database/src/main/java/org/monogram/core/database/ModelMappers.kt b/core/database/src/main/java/org/monogram/core/database/ModelMappers.kt index 7adc57fae..6703470a1 100644 --- a/core/database/src/main/java/org/monogram/core/database/ModelMappers.kt +++ b/core/database/src/main/java/org/monogram/core/database/ModelMappers.kt @@ -8,18 +8,18 @@ import org.monogram.core.database.entity.ProfileCommonEntity import org.monogram.core.database.entity.ProfileMediaEntity import org.monogram.core.database.entity.ProfileMemberEntity import org.monogram.core.models.Chat +import org.monogram.core.models.Checklists import org.monogram.core.models.Folder import org.monogram.core.models.Message import org.monogram.core.models.MessageId import org.monogram.core.models.PeerId import org.monogram.core.models.Profile -import org.monogram.core.models.ProfileMember import org.monogram.core.models.ProfileExtras -import org.monogram.core.models.Checklists +import org.monogram.core.models.ProfileMember import org.monogram.core.models.ReplyMarkups import org.monogram.core.models.TextEntities -import org.monogram.core.models.preferredPeerTitle import org.monogram.core.models.isForcedVerifiedChat +import org.monogram.core.models.preferredPeerTitle fun ChatEntity.toModel(): Chat = Chat( id = PeerId(id), @@ -35,6 +35,7 @@ fun ChatEntity.toModel(): Chat = Chat( lastMessageMediaKind = lastMessageMediaKind, lastMessageDate = lastMessageDate, photoCacheKey = photoCacheKey, + photoKnown = photoKnown, archived = archived, muted = muted, muteOverride = muteOverride, @@ -77,6 +78,7 @@ fun Chat.toEntity(): ChatEntity = ChatEntity( lastMessageMediaKind = lastMessageMediaKind, lastMessageDate = lastMessageDate, photoCacheKey = photoCacheKey, + photoKnown = photoKnown, archived = archived, muted = muted, muteOverride = muteOverride, @@ -227,6 +229,7 @@ fun PeerEntity.toProfile(): Profile { username = username, about = about, avatarCacheKey = avatarCacheKey, + avatarKnown = avatarKnown, isSelf = isSelf, status = status, statusAt = statusAt, @@ -242,10 +245,10 @@ fun Profile.toPeerEntity(prior: PeerEntity?): PeerEntity { ProfileExtras.fromProfile(this), ) val kind = when { - this.kind.isNotBlank() && - (prior?.kind == "channel" || prior?.kind == "group" || prior?.kind == "chat") && - this.kind == "user" -> prior.kind + this.kind.isNotBlank() && (prior?.kind == "channel" || prior?.kind == "group" || prior?.kind == "chat") && this.kind == "user" -> prior.kind + this.kind.isNotBlank() -> this.kind + else -> prior?.kind ?: "user" } return PeerEntity( @@ -254,7 +257,9 @@ fun Profile.toPeerEntity(prior: PeerEntity?): PeerEntity { title = preferredPeerTitle(title, prior?.title, id.value), username = username ?: prior?.username, about = about ?: prior?.about, - avatarCacheKey = avatarCacheKey ?: prior?.avatarCacheKey, + avatarCacheKey = if (avatarKnown) avatarCacheKey else avatarCacheKey + ?: prior?.avatarCacheKey, + avatarKnown = avatarKnown || avatarCacheKey != null || prior?.avatarKnown == true, status = status ?: prior?.status, statusAt = statusAt ?: prior?.statusAt, emojiStatusDocumentId = emojiStatusDocumentId ?: prior?.emojiStatusDocumentId, @@ -263,20 +268,23 @@ fun Profile.toPeerEntity(prior: PeerEntity?): PeerEntity { ) } -fun ProfileMember.toEntity(peerId: Long, position: Int, totalCount: Int = 0): ProfileMemberEntity = - ProfileMemberEntity( - peerId = peerId, - userId = id.value, - position = position, - title = title, - username = username, - avatarCacheKey = avatarCacheKey, - status = status, - statusAt = statusAt, - role = role, - rank = rank, - totalCount = totalCount, - ) +fun ProfileMember.toEntity( + peerId: Long, + position: Int, + totalCount: Int = 0, +): ProfileMemberEntity = ProfileMemberEntity( + peerId = peerId, + userId = id.value, + position = position, + title = title, + username = username, + avatarCacheKey = avatarCacheKey, + status = status, + statusAt = statusAt, + role = role, + rank = rank, + totalCount = totalCount, +) fun ProfileMemberEntity.toModel(): ProfileMember = ProfileMember( id = PeerId(userId), @@ -289,22 +297,25 @@ fun ProfileMemberEntity.toModel(): ProfileMember = ProfileMember( rank = rank, ) -fun Message.toProfileMediaEntity(peerId: Long, tab: String, position: Int): ProfileMediaEntity = - ProfileMediaEntity( - peerId = peerId, - tab = tab, - messageId = id.id, - position = position, - chatId = id.chatId.value, - text = text, - date = date, - outgoing = outgoing, - mediaCacheKey = mediaCacheKey, - mediaKind = mediaKind, - thumbCacheKey = thumbCacheKey, - fileName = fileName, - fileSize = fileSize, - ) +fun Message.toProfileMediaEntity( + peerId: Long, + tab: String, + position: Int, +): ProfileMediaEntity = ProfileMediaEntity( + peerId = peerId, + tab = tab, + messageId = id.id, + position = position, + chatId = id.chatId.value, + text = text, + date = date, + outgoing = outgoing, + mediaCacheKey = mediaCacheKey, + mediaKind = mediaKind, + thumbCacheKey = thumbCacheKey, + fileName = fileName, + fileSize = fileSize, +) fun ProfileMediaEntity.toMessage(): Message = Message( id = MessageId(chatId = PeerId(chatId), id = messageId), @@ -319,16 +330,18 @@ fun ProfileMediaEntity.toMessage(): Message = Message( fileSize = fileSize, ) -fun Chat.toProfileCommonEntity(peerId: Long, position: Int): ProfileCommonEntity = - ProfileCommonEntity( - peerId = peerId, - chatId = id.value, - position = position, - title = title, - isChannel = isChannel, - isGroup = isGroup, - photoCacheKey = photoCacheKey, - ) +fun Chat.toProfileCommonEntity( + peerId: Long, + position: Int, +): ProfileCommonEntity = ProfileCommonEntity( + peerId = peerId, + chatId = id.value, + position = position, + title = title, + isChannel = isChannel, + isGroup = isGroup, + photoCacheKey = photoCacheKey, +) fun ProfileCommonEntity.toChat(): Chat = Chat( id = PeerId(chatId), @@ -338,8 +351,8 @@ fun ProfileCommonEntity.toChat(): Chat = Chat( photoCacheKey = photoCacheKey, ) -private fun parsePeerIds(csv: String): List = - csv.split(',').mapNotNull { it.trim().takeIf(String::isNotEmpty)?.toLongOrNull()?.let(::PeerId) } +private fun parsePeerIds(csv: String): List = csv.split(',').mapNotNull { + it.trim().takeIf(String::isNotEmpty)?.toLongOrNull()?.let(::PeerId) +} -private fun peerIdsCsv(ids: List): String = - ids.joinToString(",") { it.value.toString() } +private fun peerIdsCsv(ids: List): String = ids.joinToString(",") { it.value.toString() } diff --git a/core/database/src/main/java/org/monogram/core/database/MonogramDatabase.kt b/core/database/src/main/java/org/monogram/core/database/MonogramDatabase.kt index 4c1f18cd9..fcc0049a3 100644 --- a/core/database/src/main/java/org/monogram/core/database/MonogramDatabase.kt +++ b/core/database/src/main/java/org/monogram/core/database/MonogramDatabase.kt @@ -60,7 +60,7 @@ import org.monogram.core.database.entity.UpdateCursorEntity ProfileCommonEntity::class, SponsorEntity::class, ], - version = 10, + version = 11, exportSchema = true, ) abstract class MonogramDatabase : RoomDatabase() { diff --git a/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt b/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt index c0b10feb2..46797d72a 100644 --- a/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt +++ b/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt @@ -71,6 +71,7 @@ open class OfflineWarmup( private var outgoingAccountActive = true fun outgoingAccountEpoch(): Long = outgoingAccountEpoch.get() + fun outgoingAccountIsCurrent(epoch: Long): Boolean = outgoingAccountActive && outgoingAccountEpoch.get() == epoch @@ -114,13 +115,6 @@ open class OfflineWarmup( synchronized(this) { firstPaintSnapshot = snapshot } recentWarmupChats = chats PerfLog.mark("cache:startupSnapshot", PerfLog.nowMs() - started, "count=${chats.size}") - try { - pruneExpiredCache(force = true) - } catch (cancellation: CancellationException) { - throw cancellation - } catch (error: Throwable) { - AppLog.warn("warmup", "cache ttl prune failed") - } } private suspend fun warmRecentHistory(chats: List) { @@ -143,14 +137,17 @@ open class OfflineWarmup( } } - open suspend fun chatsAfter(cursor: ChatCacheCursor, limit: Int): List = roomIo { + open suspend fun chatsAfter( + cursor: ChatCacheCursor, + limit: Int, + ): List = roomIo { db?.chatDao()?.afterCursor( cursor.archived, cursor.pinned, cursor.pinnedOrder, cursor.lastMessageDate, cursor.id, - limit + limit, )?.map { it.toModel() }.orEmpty() } @@ -209,28 +206,28 @@ open class OfflineWarmup( pruneOffset = if (chats.size < CACHE_PRUNE_CHATS) 0 else pruneOffset + chats.size for (chatId in chatsToPrune(chats, openChatId)) { currentCoroutineContext().ensureActive() - val newest = - database.messageDao().newestConfirmedIds(chatId, HISTORY_CACHE_WINDOW).toSet() - val old = database.messageDao() - .oldConfirmedIds(chatId, cutoffSeconds, CACHE_PRUNE_ROWS + newest.size) - val drop = old.filter { it !in newest }.take(CACHE_PRUNE_ROWS) - val kept = - (newest + database.messageDao().stickyIds(chatId, cutoffSeconds, 200)).distinct() - kept.minOrNull()?.let { oldestKept -> - database.messageTombstoneDao().deleteBelow(chatId, oldestKept) - } - if (drop.isNotEmpty()) { - database.messageDao().deleteInChat(chatId, drop) - database.messageTombstoneDao().deleteIds(chatId, drop) - val holes = holesAfterCacheTtl( - chatId, - database.messageHoleDao().forChat(chatId, MessageHoleEntity.SOURCE_HISTORY), - drop, - kept, - ) - val holeDao = database.messageHoleDao() - holeDao.clearChat(chatId, MessageHoleEntity.SOURCE_HISTORY) - holes.forEach { holeDao.insert(it) } + database.withTransaction { + val newest = + database.messageDao().newestConfirmedIds(chatId, HISTORY_CACHE_WINDOW).toSet() + val old = database.messageDao() + .oldConfirmedIds(chatId, cutoffSeconds, CACHE_PRUNE_ROWS + newest.size) + val drop = old.filter { it !in newest }.take(CACHE_PRUNE_ROWS) + val kept = (newest + database.messageDao() + .stickyIds(chatId, cutoffSeconds, 200)).distinct() + kept.minOrNull()?.let { oldestKept -> + database.messageTombstoneDao().deleteBelow(chatId, oldestKept) + } + if (drop.isNotEmpty()) { + database.messageDao().deleteInChat(chatId, drop) + database.messageTombstoneDao().deleteIds(chatId, drop) + val holeDao = database.messageHoleDao() + val before = holeDao.forChat(chatId, MessageHoleEntity.SOURCE_HISTORY) + val holes = holesAfterCacheTtl(chatId, before, drop, kept) + if (holes != before) { + holeDao.clearChat(chatId, MessageHoleEntity.SOURCE_HISTORY) + holes.forEach { holeDao.insert(it) } + } + } } kotlinx.coroutines.yield() } @@ -277,67 +274,123 @@ open class OfflineWarmup( open fun observeReadStates(): Flow> = db?.chatDao()?.observeReadStates() ?: emptyFlow() - open suspend fun draft(chatId: PeerId, threadId: Int = 0): String = - cloudDraftState(chatId, threadId).draft.text + open suspend fun draft( + chatId: PeerId, + threadId: Int = 0, + ): String = cloudDraftState(chatId, threadId).draft.text - open suspend fun setDraft(chatId: PeerId, threadId: Int = 0, text: String) { + open suspend fun setDraft( + chatId: PeerId, + threadId: Int = 0, + text: String, + ) { editLocalCloudDraft(chatId, threadId) { current -> current.copy( - text = text, entitiesJson = null, + text = text, + entitiesJson = null, replyToMessageId = if (text.isEmpty()) 0 else current.replyToMessageId, hasUnsupportedContent = if (text.isEmpty()) false else current.hasUnsupportedContent, ) } } - open suspend fun cloudDraftState(chatId: PeerId, threadId: Int = 0): CloudDraftState = roomIo { + open suspend fun cloudDraftState( + chatId: PeerId, + threadId: Int = 0, + ): CloudDraftState = roomIo { db?.let { readCloudDraftState(it, chatId, threadId) } ?: CloudDraftState( CloudDraft( - chatId, threadId - ) + chatId, + threadId, + ), ) } - open fun observeCloudDraft(chatId: PeerId, threadId: Int = 0): Flow = - db?.metaDao()?.observe(cloudDraftMetaKey(chatId.value, threadId)) - ?.map { cloudDraftState(chatId, threadId) }?.distinctUntilChanged() ?: emptyFlow() + open fun observeCloudDraft( + chatId: PeerId, + threadId: Int = 0, + ): Flow = db?.metaDao()?.observeDraft( + cloudDraftMetaKey(chatId.value, threadId), + draftMetaKey(chatId.value, threadId), + )?.distinctUntilChanged()?.map { rows -> + roomIo { + cloudDraftStates(rows).firstOrNull() ?: CloudDraftState( + CloudDraft( + chatId, + threadId, + ), + ) + } + }?.distinctUntilChanged() ?: emptyFlow() open fun observeCloudDraftStates(): Flow> = - db?.metaDao()?.observeLike("cloud_draft:%")?.map { cloudDraftStates() } - ?.distinctUntilChanged() ?: emptyFlow() + db?.metaDao()?.observeDrafts()?.distinctUntilChanged() + ?.map { rows -> roomIo { cloudDraftStates(rows) } }?.distinctUntilChanged() + ?: emptyFlow() open suspend fun cloudDraftStates(): List = roomIo { - val database = db ?: return@roomIo emptyList() - database.withTransaction { - val keys = database.metaDao().keysLike("cloud_draft:%") + database.metaDao() - .keysLike("draft:%") - val contexts = keys.mapNotNull(::draftContextFromMetaKey).distinct() - contexts.map { (chatId, threadId) -> readCloudDraftState(database, chatId, threadId) } - } + db?.metaDao()?.drafts()?.let(::cloudDraftStates).orEmpty() + } + + private fun cloudDraftStates(rows: List): List { + val stored = rows.associateBy { it.key } + return rows.mapNotNull { draftContextFromMetaKey(it.key) }.distinct() + .map { (chatId, threadId) -> + val modern = stored[cloudDraftMetaKey(chatId.value, threadId)] + if (modern != null) { + decodeDraftState(modern.value).also { state -> + readStoredPayload { + check(state.draft.chatId == chatId && state.draft.threadId == threadId) { + "Stored draft context does not match" + } + } + } + } else { + val legacy = stored[draftMetaKey(chatId.value, threadId)] + if (legacy == null) { + CloudDraftState(CloudDraft(chatId, threadId)) + } else { + CloudDraftState( + CloudDraft(chatId, threadId, text = legacy.value), + revision = 1, + dirty = true, + ) + } + } + } } open suspend fun saveLocalCloudDraft( - draft: CloudDraft, markdown: Boolean? = null + draft: CloudDraft, + markdown: Boolean? = null, ): CloudDraftState = editLocalCloudDraft(draft.chatId, draft.threadId, markdown = markdown) { draft } - open suspend fun discardLocalCloudDraft(chatId: PeerId, threadId: Int = 0): CloudDraftState = - editLocalCloudDraft(chatId, threadId, discardUnsupported = true) { - CloudDraft( - chatId, threadId - ) - } + open suspend fun discardLocalCloudDraft( + chatId: PeerId, + threadId: Int = 0, + ): CloudDraftState = editLocalCloudDraft(chatId, threadId, discardUnsupported = true) { + CloudDraft( + chatId, + threadId, + ) + } private suspend fun editLocalCloudDraft( - chatId: PeerId, threadId: Int, discardUnsupported: Boolean = false, - markdown: Boolean? = null, edit: (CloudDraft) -> CloudDraft, + chatId: PeerId, + threadId: Int, + discardUnsupported: Boolean = false, + markdown: Boolean? = null, + edit: (CloudDraft) -> CloudDraft, ): CloudDraftState = draftWriteMutex.withLock { roomIo { val database = db if (database == null) { val initial = CloudDraft(chatId, threadId) return@roomIo CloudDraftState(initial).localEdit( - edit(initial), discardUnsupported, markdown + edit(initial), + discardUnsupported, + markdown, ) } database.withTransaction { @@ -349,7 +402,10 @@ open class OfflineWarmup( } } - open suspend fun applyCloudDraft(draft: CloudDraft, expectedRevision: Long? = null) = roomIo { + open suspend fun applyCloudDraft( + draft: CloudDraft, + expectedRevision: Long? = null, + ) = roomIo { val database = db ?: return@roomIo database.withTransaction { val current = readCloudDraftState(database, draft.chatId, draft.threadId) @@ -359,7 +415,11 @@ open class OfflineWarmup( } } - open suspend fun acknowledgeCloudDraft(chatId: PeerId, threadId: Int, revision: Long) = roomIo { + open suspend fun acknowledgeCloudDraft( + chatId: PeerId, + threadId: Int, + revision: Long, + ) = roomIo { val database = db ?: return@roomIo database.withTransaction { val current = readCloudDraftState(database, chatId, threadId) @@ -369,7 +429,9 @@ open class OfflineWarmup( } private suspend fun readCloudDraftState( - database: MonogramDatabase, chatId: PeerId, threadId: Int + database: MonogramDatabase, + chatId: PeerId, + threadId: Int, ): CloudDraftState { val stored = database.metaDao().get(cloudDraftMetaKey(chatId.value, threadId)) if (stored != null) { @@ -383,25 +445,35 @@ open class OfflineWarmup( return CloudDraftState( CloudDraft(chatId, threadId, text = legacy?.value.orEmpty()), revision = if (legacy == null) 0 else 1, - dirty = legacy != null + dirty = legacy != null, ) } - private suspend fun writeCloudDraftState(database: MonogramDatabase, state: CloudDraftState) { + private suspend fun writeCloudDraftState( + database: MonogramDatabase, + state: CloudDraftState, + ) { database.metaDao().upsert( - org.monogram.core.database.entity.MetaEntity( + MetaEntity( cloudDraftMetaKey(state.draft.chatId.value, state.draft.threadId), - encodeDraftState(state) - ) + encodeDraftState(state), + ), ) database.metaDao().delete(draftMetaKey(state.draft.chatId.value, state.draft.threadId)) } - open suspend fun discussionReadMax(chatId: PeerId, topId: Int): Int = roomIo { + open suspend fun discussionReadMax( + chatId: PeerId, + topId: Int, + ): Int = roomIo { db?.metaDao()?.get("discussion_read:${chatId.value}:$topId")?.value?.toIntOrNull() ?: 0 } - open suspend fun applyDiscussionRead(chatId: PeerId, topId: Int, maxId: Int) = roomIo { + open suspend fun applyDiscussionRead( + chatId: PeerId, + topId: Int, + maxId: Int, + ) = roomIo { val database = db ?: return@roomIo database.withTransaction { val confirmed = maxOf( @@ -410,7 +482,7 @@ open class OfflineWarmup( maxId, ) database.metaDao().upsert( - org.monogram.core.database.entity.MetaEntity( + MetaEntity( "discussion_read:${chatId.value}:$topId", confirmed.toString(), ), @@ -418,29 +490,48 @@ open class OfflineWarmup( } } - open suspend fun applyOutboxRead(chatId: PeerId, maxId: Int) = roomIo { + open suspend fun applyOutboxRead( + chatId: PeerId, + maxId: Int, + ) = roomIo { recentHistory.invalidate(chatId.value) db?.chatDao()?.updateOutboxRead(chatId.value, maxId) } - open suspend fun applyReactions(chatId: PeerId, messageId: Int, json: String) = roomIo { + open suspend fun applyReactions( + chatId: PeerId, + messageId: Int, + json: String, + ) = roomIo { recentHistory.invalidate(chatId.value) db?.messageDao()?.updateReactions(chatId.value, messageId, json) } - open suspend fun applyUnreadMentions(chatId: PeerId, stillUnread: Int) = roomIo { + open suspend fun applyUnreadMentions( + chatId: PeerId, + stillUnread: Int, + ) = roomIo { db?.chatDao()?.updateUnreadMentions(chatId.value, stillUnread.coerceAtLeast(0)) } - open suspend fun applyUnreadReactions(chatId: PeerId, stillUnread: Int) = roomIo { + open suspend fun applyUnreadReactions( + chatId: PeerId, + stillUnread: Int, + ) = roomIo { db?.chatDao()?.updateUnreadReactions(chatId.value, stillUnread.coerceAtLeast(0)) } - open suspend fun addUnreadMentions(chatId: PeerId, delta: Int) = roomIo { + open suspend fun addUnreadMentions( + chatId: PeerId, + delta: Int, + ) = roomIo { db?.chatDao()?.addUnreadMentions(chatId.value, delta) } - open suspend fun addUnreadReactions(chatId: PeerId, delta: Int) = roomIo { + open suspend fun addUnreadReactions( + chatId: PeerId, + delta: Int, + ) = roomIo { db?.chatDao()?.addUnreadReactions(chatId.value, delta) } @@ -448,13 +539,16 @@ open class OfflineWarmup( recentHistory.invalidate(message.id.chatId.value) val database = db ?: return@roomIo database.withTransaction { - if (mergeMessages(listOf(message)).isNotEmpty()) { + if (mergeMessages(listOf(message)).accepted.isNotEmpty()) { refreshPreview(message.id.chatId, message.id.id) } } } - private suspend fun refreshPreview(chatId: PeerId, changedId: Int) { + private suspend fun refreshPreview( + chatId: PeerId, + changedId: Int, + ) { val database = db ?: return val chat = database.chatDao().get(chatId.value)?.toModel() ?: return if (chat.lastMessageId != changedId) return @@ -491,7 +585,10 @@ open class OfflineWarmup( } } - open suspend fun chatsWindow(limit: Int, archiveLimit: Int = 3): List { + open suspend fun chatsWindow( + limit: Int, + archiveLimit: Int = 3, + ): List { val database = db ?: run { ensureStartupCleanup() return chats() @@ -550,12 +647,19 @@ open class OfflineWarmup( } } - open suspend fun markMessageFailed(chatId: PeerId, messageId: Int) = roomIo { + open suspend fun markMessageFailed( + chatId: PeerId, + messageId: Int, + ) = roomIo { recentHistory.invalidate(chatId.value) db?.messageDao()?.markFailed(chatId.value, messageId) } - open suspend fun reconcileOutgoing(pendingId: Int, message: Message, randomId: Long?) = roomIo { + open suspend fun reconcileOutgoing( + pendingId: Int, + message: Message, + randomId: Long?, + ) = roomIo { require(message.id.id > 0) recentHistory.invalidate(message.id.chatId.value) val database = db @@ -575,7 +679,10 @@ open class OfflineWarmup( } } - open suspend fun messages(chatId: PeerId, limit: Int = HISTORY_CACHE_WINDOW): List { + open suspend fun messages( + chatId: PeerId, + limit: Int = HISTORY_CACHE_WINDOW, + ): List { val database = db ?: run { ensureStartupCleanup() return emptyList() @@ -589,7 +696,10 @@ open class OfflineWarmup( } } - open suspend fun messagesByIds(chatId: PeerId, ids: List): List { + open suspend fun messagesByIds( + chatId: PeerId, + ids: List, + ): List { if (ids.isEmpty()) return emptyList() val database = db ?: run { ensureStartupCleanup() @@ -607,7 +717,9 @@ open class OfflineWarmup( } open suspend fun olderMessages( - chatId: PeerId, beforeId: Int, limit: Int = HISTORY_CACHE_WINDOW + chatId: PeerId, + beforeId: Int, + limit: Int = HISTORY_CACHE_WINDOW, ): List { val database = db ?: run { ensureStartupCleanup() @@ -616,23 +728,27 @@ open class OfflineWarmup( return DatabaseProvider.onRoom { ensureStartupCleanup() val rows = database.messageDao().olderThan(chatId.value, beforeId, limit) - if (database.metaDao() - .get("history_coverage:${chatId.value}") == null - ) return@onRoom emptyList() + if (database.metaDao().get("history_coverage:${chatId.value}") == null) { + return@onRoom emptyList() + } if (rows.isEmpty()) return@onRoom emptyList() if (database.messageHoleDao().overlaps( chatId.value, rows.minOf { it.id }, beforeId - 1, - MessageHoleEntity.SOURCE_HISTORY + MessageHoleEntity.SOURCE_HISTORY, ) - ) return@onRoom emptyList() + ) { + return@onRoom emptyList() + } rows.map { it.toModel() } } } open suspend fun newerMessages( - chatId: PeerId, afterId: Int, limit: Int = HISTORY_CACHE_WINDOW + chatId: PeerId, + afterId: Int, + limit: Int = HISTORY_CACHE_WINDOW, ): List { val database = db ?: run { ensureStartupCleanup() @@ -641,17 +757,19 @@ open class OfflineWarmup( return DatabaseProvider.onRoom { ensureStartupCleanup() val rows = database.messageDao().newerThan(chatId.value, afterId, limit) - if (database.metaDao() - .get("history_coverage:${chatId.value}") == null - ) return@onRoom emptyList() + if (database.metaDao().get("history_coverage:${chatId.value}") == null) { + return@onRoom emptyList() + } if (rows.isEmpty()) return@onRoom emptyList() if (database.messageHoleDao().overlaps( chatId.value, afterId + 1, rows.maxOf { it.id }, - MessageHoleEntity.SOURCE_HISTORY + MessageHoleEntity.SOURCE_HISTORY, ) - ) return@onRoom emptyList() + ) { + return@onRoom emptyList() + } rows.map { it.toModel() } } } @@ -665,14 +783,28 @@ open class OfflineWarmup( val database = db ?: return HistorySlice(emptyList(), false, emptyList(), false, false, false) return DatabaseProvider.onRoom { - val rows = when { - beforeId != null -> database.messageDao().olderThan(chatId.value, beforeId, limit) - afterId != null -> database.messageDao().newerThan(chatId.value, afterId, limit) - else -> database.messageDao().latestForChat(chatId.value, limit) - }.map { it.toModel() } - val holes = - database.messageHoleDao().forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) - val initialized = database.metaDao().get("history_coverage:${chatId.value}") != null + val (entities, holes, initialized) = database.withTransaction { + val rows = when { + beforeId != null -> { + database.messageDao().olderThan(chatId.value, beforeId, limit) + } + + afterId != null -> { + database.messageDao().newerThan(chatId.value, afterId, limit) + } + + else -> { + database.messageDao().latestForChat(chatId.value, limit) + } + } + Triple( + rows, + database.messageHoleDao() + .forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY), + database.metaDao().get("history_coverage:${chatId.value}") != null, + ) + } + val rows = entities.map { it.toModel() } val start = when { beforeId != null -> (rows.minOfOrNull { it.id.id } ?: 1) afterId != null -> afterId + 1 @@ -687,7 +819,11 @@ open class OfflineWarmup( } } - open suspend fun hasHistoryHole(chatId: PeerId, startId: Int, endId: Int): Boolean = roomIo { + open suspend fun hasHistoryHole( + chatId: PeerId, + startId: Int, + endId: Int, + ): Boolean = roomIo { db?.messageHoleDao() ?.overlaps(chatId.value, startId, endId, MessageHoleEntity.SOURCE_HISTORY) ?: false } @@ -708,16 +844,12 @@ open class OfflineWarmup( database.withTransaction { val metaKey = "history_coverage:${chatId.value}" val holeDao = database.messageHoleDao() + val before = holeDao.forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + var holes = before if (database.metaDao().get(metaKey) == null) { - val holes = mergeHistoryHoles( - holeDao.forChat( - chatId.value, MessageHoleEntity.SOURCE_HISTORY - ) + MessageHoleEntity(chatId.value, 1, Int.MAX_VALUE) - ) - holeDao.clearChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) - holes.forEach { holeDao.insert(it) } - database.metaDao() - .upsert(org.monogram.core.database.entity.MetaEntity(metaKey, "1")) + holes = + mergeHistoryHoles(before + MessageHoleEntity(chatId.value, 1, Int.MAX_VALUE)) + database.metaDao().upsert(MetaEntity(metaKey, "1")) } mergeMessages(messages) val ids = messages.filter { !it.pending && it.id.id > 0 }.map { it.id.id } @@ -725,8 +857,9 @@ open class OfflineWarmup( val max = if (reachesNewest) Int.MAX_VALUE else olderBoundary?.minus(1) ?: ids.maxOrNull() if (min != null && max != null && min <= max) { - val holes = holeDao.forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) - .flatMap { subtractHistoryRange(it, min, max) } + holes = holes.flatMap { subtractHistoryRange(it, min, max) } + } + if (holes != before) { holeDao.clearChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) holes.forEach { holeDao.insert(it) } } @@ -734,30 +867,39 @@ open class OfflineWarmup( pruneExpiredCache() } - open suspend fun markHistoryHole(chatId: PeerId, startId: Int, endId: Int) = roomIo { + open suspend fun markHistoryHole( + chatId: PeerId, + startId: Int, + endId: Int, + ) = roomIo { if (startId > endId) return@roomIo val database = db ?: return@roomIo database.withTransaction { val dao = database.messageHoleDao() - val holes = mergeHistoryHoles( - dao.forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + MessageHoleEntity( - chatId.value, startId, endId - ) - ) - dao.clearChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) - holes.forEach { dao.insert(it) } + val before = dao.forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + val holes = mergeHistoryHoles(before + MessageHoleEntity(chatId.value, startId, endId)) + if (holes != before) { + dao.clearChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + holes.forEach { dao.insert(it) } + } } } - open suspend fun closeHistoryHole(chatId: PeerId, startId: Int, endId: Int) = roomIo { + open suspend fun closeHistoryHole( + chatId: PeerId, + startId: Int, + endId: Int, + ) = roomIo { if (startId > endId) return@roomIo val database = db ?: return@roomIo database.withTransaction { val dao = database.messageHoleDao() - val holes = dao.forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) - .flatMap { subtractHistoryRange(it, startId, endId) } - dao.clearChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) - holes.forEach { dao.insert(it) } + val before = dao.forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + val holes = before.flatMap { subtractHistoryRange(it, startId, endId) } + if (holes != before) { + dao.clearChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + holes.forEach { dao.insert(it) } + } } } @@ -777,6 +919,26 @@ open class OfflineWarmup( } } + open suspend fun invalidateCachedHistory(chatId: Long) = roomIo { + recentHistory.invalidate(chatId) + val database = db ?: return@roomIo + database.withTransaction { + database.messageDao().clearConfirmedHistory(chatId) + database.messageHoleDao().clearChat(chatId, MessageHoleEntity.SOURCE_HISTORY) + database.metaDao().delete("history_coverage:$chatId") + } + } + + open suspend fun invalidateCachedHistories() = roomIo { + recentHistory.clear() + val database = db ?: return@roomIo + database.withTransaction { + database.messageDao().clearConfirmedHistory() + database.messageHoleDao().clearAll() + database.metaDao().deletePrefix("history_coverage:") + } + } + open suspend fun replaceChats(chats: List) = upsertChats(chats) open suspend fun applyProfileToChat(profile: Profile) { @@ -787,7 +949,9 @@ open class OfflineWarmup( title = title, isChannel = profile.kind == "channel", isGroup = profile.kind == "group" || profile.kind == "chat", - photoCacheKey = profile.avatarCacheKey ?: stored.photoCacheKey, + photoCacheKey = if (profile.avatarKnown) profile.avatarCacheKey else profile.avatarCacheKey + ?: stored.photoCacheKey, + photoKnown = profile.avatarKnown || profile.avatarCacheKey != null || stored.photoKnown, peerStatus = profile.status ?: stored.peerStatus, peerStatusAt = profile.statusAt ?: stored.peerStatusAt, emojiStatusDocumentId = profile.emojiStatusDocumentId ?: stored.emojiStatusDocumentId, @@ -802,11 +966,10 @@ open class OfflineWarmup( database.withTransaction { val stored = database.chatDao().getByIds(chats.map { it.id.value }).associateBy { it.id } - database.chatDao().upsertAll( - chats.map { chat -> - chat.mergeLocalCache(stored[chat.id.value]?.toModel()).toEntity() - }, - ) + val changed = chats.map { chat -> + chat.mergeLocalCache(stored[chat.id.value]?.toModel()).toEntity() + }.associateBy { it.id }.values.filter { it != stored[it.id] } + if (changed.isNotEmpty()) database.chatDao().upsertAll(changed) } } } @@ -821,7 +984,10 @@ open class OfflineWarmup( } } - open suspend fun replaceMessages(chatId: PeerId, messages: List) = roomIo { + open suspend fun replaceMessages( + chatId: PeerId, + messages: List, + ) = roomIo { recentHistory.invalidate(chatId.value) val database = db ?: return@roomIo database.withTransaction { @@ -831,16 +997,23 @@ open class OfflineWarmup( } suspend fun applyMessageRead( - expectedEpoch: Long, chatId: PeerId, messageId: Int, message: Message? + expectedEpoch: Long, + chatId: PeerId, + messageId: Int, + message: Message?, ): Boolean = draftWriteMutex.withLock { require(messageId > 0) require(message == null || message.id.chatId == chatId && message.id.id == messageId) if (!outgoingAccountIsCurrent(expectedEpoch)) return@withLock false - if (message == null) deleteMessages(chatId, setOf(messageId)) else upsertMessages( - listOf( - message + if (message == null) { + deleteMessages(chatId, setOf(messageId)) + } else { + upsertMessages( + listOf( + message, + ), ) - ) + } true } @@ -860,34 +1033,50 @@ open class OfflineWarmup( pruneExpiredCache() } - private suspend fun mergeMessages(messages: List): List { - val database = db ?: return emptyList() + private data class MergedMessages( + val accepted: List, + val alreadyStored: List, + ) + + private suspend fun mergeMessages(messages: List): MergedMessages { + val database = db ?: return MergedMessages(emptyList(), emptyList()) val accepted = mutableListOf() + val alreadyStored = mutableListOf() + val changed = mutableListOf() + val deleted = mutableSetOf() + val stored = mutableMapOf() for ((chatId, rows) in messages.groupBy { it.id.chatId.value }) { val ids = rows.map { it.id.id } - val deleted = database.messageTombstoneDao().deletedIds(chatId, ids).toSet() - val stored = - database.messageDao().byIds(chatId, ids).associateBy { it.id }.toMutableMap() - for (message in rows) { - if (message.id.id in deleted) continue - val incoming = message.toEntity() - val previous = stored[incoming.id] - if (previous != null && (previous.editDate ?: 0L) > (incoming.editDate - ?: 0L) - ) continue - stored[incoming.id] = incoming - accepted += message - } - } - if (accepted.isNotEmpty()) database.messageDao().upsertAll(accepted.map { it.toEntity() }) - return accepted - } - - open suspend fun deleteMessage(chatId: PeerId, messageId: Int) = roomIo { + deleted += database.messageTombstoneDao().deletedIds(chatId, ids) + .map { MessageId(PeerId(chatId), it) } + database.messageDao().byIds(chatId, ids) + .forEach { stored[MessageId(PeerId(chatId), it.id)] = it } + } + for (message in messages) { + if (message.id in deleted) continue + val incoming = message.toEntity() + val previous = stored[message.id] + if (previous != null && (previous.editDate ?: 0L) > (incoming.editDate ?: 0L)) continue + if (previous != incoming) changed += incoming + stored[message.id] = incoming + accepted += message + alreadyStored += (previous != null) + } + if (changed.isNotEmpty()) database.messageDao().upsertAll(changed) + return MergedMessages(accepted, alreadyStored) + } + + open suspend fun deleteMessage( + chatId: PeerId, + messageId: Int, + ) = roomIo { deleteMessages(chatId, listOf(messageId)) } - open suspend fun deleteMessages(chatId: PeerId?, messageIds: Collection) = roomIo { + open suspend fun deleteMessages( + chatId: PeerId?, + messageIds: Collection, + ) = roomIo { if (chatId == null) recentHistory.clear() else recentHistory.invalidate(chatId.value) val database = db ?: return@roomIo val ids = messageIds.toList() @@ -897,9 +1086,13 @@ open class OfflineWarmup( // while this transaction is open and then open a nested one. val affected = database.chatDao().withLastMessageIds(chatId?.value, ids).map { it.toModel() } - database.messageTombstoneDao().insert(ids.filter { it > 0 }.map { - org.monogram.core.database.entity.MessageTombstoneEntity(chatId?.value ?: 0L, it) - }) + database.messageTombstoneDao().insert( + ids.filter { it > 0 }.map { + org.monogram.core.database.entity.MessageTombstoneEntity( + chatId?.value ?: 0L, it + ) + }, + ) if (chatId != null) { database.messageDao().deleteInChat(chatId.value, ids) } else { @@ -931,7 +1124,10 @@ open class OfflineWarmup( } /** Drops cached rows in the latest window that the server no longer returns. */ - open suspend fun pruneMissingLatest(chatId: PeerId, fetched: List) = roomIo { + open suspend fun pruneMissingLatest( + chatId: PeerId, + fetched: List, + ) = roomIo { recentHistory.invalidate(chatId.value) val database = db ?: return@roomIo if (fetched.isEmpty()) return@roomIo @@ -954,18 +1150,63 @@ open class OfflineWarmup( recentHistory.invalidate(message.id.chatId.value) val database = db ?: return@roomIo database.withTransaction { - val existed = - database.messageDao().byIds(message.id.chatId.value, listOf(message.id.id)) - .isNotEmpty() - if (mergeMessages(listOf(message)).isEmpty()) return@withTransaction + val merged = mergeMessages(listOf(message)) + if (merged.accepted.isEmpty()) return@withTransaction val stored = database.chatDao().get(message.id.chatId.value)?.toModel() val chat = (stored ?: placeholderChat(message.id.chatId)).mergeIncomingMessage( message, - alreadyStored = existed, + alreadyStored = merged.alreadyStored.single(), accountGeneration = accountGeneration, acceptedOrder = acceptedOrder, ) - database.chatDao().upsertAll(listOf(chat.toEntity())) + if (chat != stored) database.chatDao().upsertAll(listOf(chat.toEntity())) + } + } + + open suspend fun applyIncomingMessages( + messages: List, + accountGeneration: Long = 0, + acceptedOrder: Long = 0, + ) { + if (db == null) { + for (message in messages) { + currentCoroutineContext().ensureActive() + applyIncomingMessage(message, accountGeneration, acceptedOrder) + } + return + } + roomIo { + val database = checkNotNull(db) + for (batch in messages.chunked(HISTORY_CACHE_WINDOW)) { + currentCoroutineContext().ensureActive() + batch.map { it.id.chatId.value }.distinct().forEach(recentHistory::invalidate) + database.withTransaction { + val merged = mergeMessages(batch) + if (merged.accepted.isEmpty()) return@withTransaction + val stored = database.chatDao() + .getByIds(merged.accepted.map { it.id.chatId.value }.distinct()) + .associateBy { it.id } + val chats = stored.mapValues { it.value.toModel() }.toMutableMap() + val touched = mutableSetOf() + merged.accepted.forEachIndexed { index, message -> + val chatId = message.id.chatId.value + val previous = chats[chatId] + val chat = + (previous ?: placeholderChat(message.id.chatId)).mergeIncomingMessage( + message, + alreadyStored = merged.alreadyStored[index], + accountGeneration = accountGeneration, + acceptedOrder = acceptedOrder, + ) + if (chat != previous) touched += chatId + chats[chatId] = chat + } + val changed = + touched.map { chats.getValue(it).toEntity() }.filter { it != stored[it.id] } + if (changed.isNotEmpty()) database.chatDao().upsertAll(changed) + } + kotlinx.coroutines.yield() + } } } @@ -982,7 +1223,7 @@ open class OfflineWarmup( channelCursors: Map, apply: suspend () -> Unit, ): Boolean = roomIo { - val database = db ?: return@roomIo true + val database = db ?: return@roomIo false database.withTransaction { val dao = database.updateCursorDao() if (dao.isBatchApplied(accountGeneration, batchId)) return@withTransaction true @@ -996,7 +1237,11 @@ open class OfflineWarmup( } } dao.insertReceipt( - UpdateBatchReceiptEntity(accountGeneration, batchId, System.currentTimeMillis()) + UpdateBatchReceiptEntity( + accountGeneration, + batchId, + System.currentTimeMillis(), + ), ) dao.pruneReceipts(accountGeneration, batchId - RECEIPT_WINDOW) true @@ -1016,8 +1261,9 @@ open class OfflineWarmup( ?: 0L database.metaDao().upsert( MetaEntity( - "outgoing_send.blocked_until_ms", maxOf(old, deadline).toString() - ) + "outgoing_send.blocked_until_ms", + maxOf(old, deadline).toString(), + ), ) } } @@ -1026,8 +1272,16 @@ open class OfflineWarmup( db?.outgoingTextSendDao()?.observeAll() ?: flowOf(emptyList()) open suspend fun outgoingTextSends() = roomIo { db?.outgoingTextSendDao()?.all().orEmpty() } + + open suspend fun hasOutgoingTextSend(randomId: Long): Boolean { + val database = db ?: return outgoingTextSends().any { it.randomId == randomId } + return roomIo { database.outgoingTextSendDao().exists(randomId) } + } + open suspend fun captureOutgoingTextDraft( - chatId: PeerId, threadId: Int, expectedEpoch: Long + chatId: PeerId, + threadId: Int, + expectedEpoch: Long, ): CloudDraftState = draftWriteMutex.withLock { check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) cloudDraftState(chatId, threadId) @@ -1035,18 +1289,27 @@ open class OfflineWarmup( /** Persists the send; returns whether its matching draft was consumed in the same transaction. */ open suspend fun contactImportAttempt( - spec: ContactSendSpec, epoch: Long + spec: ContactSendSpec, + epoch: Long, ): ContactImportAttempt? = draftWriteMutex.withLock { check(outgoingAccountIsCurrent(epoch)) val key = contactImportMetaKey(spec) - val value = roomIo { db?.metaDao()?.get(key)?.value?.let(::decodeContactImport) } - if (value?.phase == ContactImportPhase.IN_FLIGHT && (epoch to key) !in activeContactImports) value.copy( - phase = ContactImportPhase.UNCERTAIN - ) else value + val value = roomIo { + db?.metaDao()?.get(key)?.value?.let(::decodeContactImport) + } + if (value?.phase == ContactImportPhase.IN_FLIGHT && (epoch to key) !in activeContactImports) { + value.copy( + phase = ContactImportPhase.UNCERTAIN, + ) + } else { + value + } } open suspend fun beginContactImport( - spec: ContactSendSpec, epoch: Long, retry: Boolean + spec: ContactSendSpec, + epoch: Long, + retry: Boolean, ): ContactImportAttempt = draftWriteMutex.withLock { check(outgoingAccountIsCurrent(epoch)) spec.requireValid() @@ -1061,17 +1324,19 @@ open class OfflineWarmup( ContactImportPhase.UNCERTAIN, ContactImportPhase.RETRY_REQUIRED, ContactImportPhase.NOT_ADDED, - ContactImportPhase.IN_FLIGHT - ) + ContactImportPhase.IN_FLIGHT, + ), + ) + val identity = previous?.spec ?: ContactImportSpec( + randomUUID().mostSignificantBits.takeIf { it != 0L } ?: 1L, + spec.phone, + spec.firstName, + spec.lastName, ) - val identity = previous?.spec - ?: ContactImportSpec(randomUUID().mostSignificantBits.takeIf { it != 0L } ?: 1L, - spec.phone, - spec.firstName, - spec.lastName) check(identity.phone == spec.phone && identity.firstName == spec.firstName && identity.lastName == spec.lastName) ContactImportAttempt( - identity, ContactImportPhase.IN_FLIGHT + identity, + ContactImportPhase.IN_FLIGHT, ).also { database.metaDao().upsert(MetaEntity(key, encodeContactImport(it))) } @@ -1082,7 +1347,9 @@ open class OfflineWarmup( } open suspend fun finishContactImport( - spec: ContactSendSpec, epoch: Long, attempt: ContactImportAttempt + spec: ContactSendSpec, + epoch: Long, + attempt: ContactImportAttempt, ): Boolean = draftWriteMutex.withLock { val key = contactImportMetaKey(spec) try { @@ -1117,18 +1384,24 @@ open class OfflineWarmup( val current = draftSnapshot?.let { require(it.draft.chatId.value == row.message.chatId) readCloudDraftState( - database, it.draft.chatId, it.draft.threadId + database, + it.draft.chatId, + it.draft.threadId, ).also { state -> check(!state.draft.hasUnsupportedContent) } } require(database.outgoingMediaSendDao().item(row.randomId) == null) database.outgoingTextSendDao().enqueue(row) - if (!consumeDraft || draftSnapshot == null || current == null || current.revision != draftSnapshot.revision || current.draft != draftSnapshot.draft || current.markdown != draftSnapshot.markdown) return@withTransaction false + if (!consumeDraft || draftSnapshot == null || current == null || current.revision != draftSnapshot.revision || current.draft != draftSnapshot.draft || current.markdown != draftSnapshot.markdown) { + return@withTransaction false + } writeCloudDraftState( - database, current.localEdit( - CloudDraft(current.draft.chatId, current.draft.threadId), markdown = false - ) + database, + current.localEdit( + CloudDraft(current.draft.chatId, current.draft.threadId), + markdown = false, + ), ) true } @@ -1139,25 +1412,39 @@ open class OfflineWarmup( db?.outgoingMediaSendDao()?.observeAll() ?: flowOf(emptyList()) open suspend fun outgoingMediaSends() = roomIo { db?.outgoingMediaSendDao()?.all().orEmpty() } + + open suspend fun hasOutgoingMediaSend(batchId: Long): Boolean { + val database = db ?: return outgoingMediaSends().any { it.batch.batchId == batchId } + return roomIo { database.outgoingMediaSendDao().exists(batchId) } + } + open suspend fun claimOutgoingMediaSend(batchId: Long): Boolean = roomIo { db?.outgoingMediaSendDao()?.claim(batchId) == 1 } - open suspend fun recoverInterruptedMediaSends() = - roomIo { db?.outgoingMediaSendDao()?.recoverInterrupted(); Unit } + open suspend fun recoverInterruptedMediaSends() = roomIo { + db?.outgoingMediaSendDao()?.recoverInterrupted() + Unit + } - open suspend fun markOutgoingMediaSendUncertain(batchId: Long) = - roomIo { db?.outgoingMediaSendDao()?.markUncertain(batchId); Unit } + open suspend fun markOutgoingMediaSendUncertain(batchId: Long) = roomIo { + db?.outgoingMediaSendDao()?.markUncertain(batchId) + Unit + } - open suspend fun markOutgoingMediaSendFailed(batchId: Long) = - roomIo { db?.outgoingMediaSendDao()?.markFailed(batchId); Unit } + open suspend fun markOutgoingMediaSendFailed(batchId: Long) = roomIo { + db?.outgoingMediaSendDao()?.markFailed(batchId) + Unit + } open suspend fun retryOutgoingMediaSend(batchId: Long) = roomIo { if (db?.outgoingMediaSendDao()?.retry(batchId) == 1) outgoingSendSync.requestResume() } open suspend fun enqueueOutgoingMediaSend( - send: OutgoingMediaSend, expectedEpoch: Long, - draftSnapshot: CloudDraftState? = null, consumeDraft: Boolean = true, + send: OutgoingMediaSend, + expectedEpoch: Long, + draftSnapshot: CloudDraftState? = null, + consumeDraft: Boolean = true, ): Boolean = draftWriteMutex.withLock { check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) enqueueMediaLocked(send, draftSnapshot, consumeDraft) @@ -1172,18 +1459,30 @@ open class OfflineWarmup( database.withTransaction { val current = draftSnapshot?.let { require(it.draft.chatId.value == send.batch.chatId) - require(it.draft.threadId == (send.items.firstOrNull()?.message?.replyToTopId ?: 0)) + require( + it.draft.threadId == (send.items.firstOrNull()?.message?.replyToTopId ?: 0), + ) readCloudDraftState( - database, it.draft.chatId, it.draft.threadId + database, + it.draft.chatId, + it.draft.threadId, ).also { state -> check(!state.draft.hasUnsupportedContent) } } - send.items.forEach { require(database.outgoingTextSendDao().get(it.randomId) == null) } + send.items.forEach { + require( + database.outgoingTextSendDao().get(it.randomId) == null, + ) + } database.outgoingMediaSendDao().enqueue(send) - if (!consumeDraft || draftSnapshot == null || current == null || current.revision != draftSnapshot.revision || current.draft != draftSnapshot.draft || current.markdown != draftSnapshot.markdown) return@withTransaction false + if (!consumeDraft || draftSnapshot == null || current == null || current.revision != draftSnapshot.revision || current.draft != draftSnapshot.draft || current.markdown != draftSnapshot.markdown) { + return@withTransaction false + } writeCloudDraftState( - database, current.localEdit( - CloudDraft(current.draft.chatId, current.draft.threadId), markdown = false - ) + database, + current.localEdit( + CloudDraft(current.draft.chatId, current.draft.threadId), + markdown = false, + ), ) true } @@ -1224,14 +1523,18 @@ open class OfflineWarmup( upload.mimeType, retained[index], upload.message.copy( - mediaCacheKey = path, thumbCacheKey = path, fileSize = retained[index].size - ) + mediaCacheKey = path, + thumbCacheKey = path, + fileSize = retained[index].size, + ), ) } val send = OutgoingMediaSend(batch, items) val consumed = withContext(NonCancellable) { enqueueMediaLocked( - send, draftSnapshot, consumeDraft + send, + draftSnapshot, + consumeDraft, ) } OutgoingMediaAdmission(send, consumed) @@ -1250,14 +1553,20 @@ open class OfflineWarmup( open suspend fun claimOutgoingTextSend(randomId: Long): Boolean = roomIo { db?.outgoingTextSendDao()?.claim(randomId) == 1 } - open suspend fun recoverInterruptedTextSends() = - roomIo { db?.outgoingTextSendDao()?.recoverInterrupted(); Unit } + open suspend fun recoverInterruptedTextSends() = roomIo { + db?.outgoingTextSendDao()?.recoverInterrupted() + Unit + } - open suspend fun markOutgoingTextSendUncertain(randomId: Long) = - roomIo { db?.outgoingTextSendDao()?.markUncertain(randomId); Unit } + open suspend fun markOutgoingTextSendUncertain(randomId: Long) = roomIo { + db?.outgoingTextSendDao()?.markUncertain(randomId) + Unit + } - open suspend fun markOutgoingTextSendFailed(randomId: Long) = - roomIo { db?.outgoingTextSendDao()?.markFailed(randomId); Unit } + open suspend fun markOutgoingTextSendFailed(randomId: Long) = roomIo { + db?.outgoingTextSendDao()?.markFailed(randomId) + Unit + } open suspend fun retryOutgoingTextSend(randomId: Long) = roomIo { if (db?.outgoingTextSendDao()?.retry(randomId) == 1) outgoingSendSync.requestResume() @@ -1276,8 +1585,10 @@ open class OfflineWarmup( database.withTransaction { val send = database.outgoingMediaSendDao().get(batchId) ?: return@withTransaction val ids = results.map { it.first } - require(ids.size == ids.toSet().size && ids.toSet() == send.items.map { it.randomId } - .toSet()) + require( + ids.size == ids.toSet().size && ids.toSet() == send.items.map { it.randomId } + .toSet(), + ) val messageIds = results.map { (_, result) -> val message = when (result) { is OutgoingMessageResult.Sent -> result.message @@ -1289,7 +1600,10 @@ open class OfflineWarmup( require(messageIds.toSet().size == messageIds.size) results.forEach { (randomId, result) -> applyOutgoingResult( - randomId, result, accountGeneration, acceptedOrder + randomId, + result, + accountGeneration, + acceptedOrder, ) } database.outgoingMediaSendDao().acknowledge(batchId, send.batch.chatId, ids.toSet()) @@ -1297,17 +1611,21 @@ open class OfflineWarmup( } open suspend fun applyOutgoingResult( - randomId: Long, result: OutgoingMessageResult, - accountGeneration: Long = 0, acceptedOrder: Long = 0, + randomId: Long, + result: OutgoingMessageResult, + accountGeneration: Long = 0, + acceptedOrder: Long = 0, ) = roomIo { require(randomId != 0L) val database = db ?: return@roomIo database.withTransaction { val retained = database.outgoingTextSendDao().get(randomId) when (result) { - is OutgoingMessageResult.Scheduled -> applyScheduledMessage( - result.message.copy(message = result.message.message.copy(randomId = randomId)), - ) + is OutgoingMessageResult.Scheduled -> { + applyScheduledMessage( + result.message.copy(message = result.message.message.copy(randomId = randomId)), + ) + } is OutgoingMessageResult.Sent -> { val message = @@ -1315,29 +1633,46 @@ open class OfflineWarmup( val pending = database.messageDao().pendingByRandomId(message.id.chatId.value, randomId) require(pending.size <= 1) - if (pending.isNotEmpty()) reconcileOutgoing( - pending.single().id, message, randomId - ) - else applyIncomingMessage(message, accountGeneration, acceptedOrder) + if (pending.isNotEmpty()) { + reconcileOutgoing( + pending.single().id, + message, + randomId, + ) + } else { + applyIncomingMessage(message, accountGeneration, acceptedOrder) + } if (retained?.message?.mediaKind == "geo" && decodeLocationSend(retained.message.fileName).live) { val place = org.monogram.core.models.ServiceMedia.geo(message.fileName) if (place?.live == true && place.periodSeconds > 0) { val key = liveLocationKey(message.id) val existing = database.metaDao().get(key) if (existing == null) { - val deadline = - if (place.periodSeconds == Int.MAX_VALUE) null else Math.addExact( - message.date, place.periodSeconds.toLong() + val deadline = if (place.periodSeconds == Int.MAX_VALUE) { + null + } else { + Math.addExact( + message.date, + place.periodSeconds.toLong(), ) + } val phase = - if (deadline != null && deadline <= System.currentTimeMillis() / 1000) LiveLocationPhase.EXPIRED - else LiveLocationPhase.ACTIVE + if (deadline != null && deadline <= System.currentTimeMillis() / 1000) { + LiveLocationPhase.EXPIRED + } else { + LiveLocationPhase.ACTIVE + } val session = LiveLocationSession( - message.id, randomId, deadline, phase + message.id, + randomId, + deadline, + phase, ) database.metaDao() .upsert(MetaEntity(key, encodeLiveLocation(session))) - } else require(decodeLiveLocation(existing.value).randomId == randomId) + } else { + require(decodeLiveLocation(existing.value).randomId == randomId) + } } } } @@ -1360,7 +1695,8 @@ open class OfflineWarmup( } ?: flowOf(emptyList()) open suspend fun liveLocation( - id: MessageId, epoch: Long + id: MessageId, + epoch: Long, ): LiveLocationSession? = draftWriteMutex.withLock { check(outgoingAccountIsCurrent(epoch)) roomIo { @@ -1375,7 +1711,10 @@ open class OfflineWarmup( } open suspend fun transitionLiveLocation( - id: MessageId, revision: Long, phase: LiveLocationPhase, epoch: Long + id: MessageId, + revision: Long, + phase: LiveLocationPhase, + epoch: Long, ): Boolean = draftWriteMutex.withLock { check(outgoingAccountIsCurrent(epoch)) roomIo { @@ -1386,16 +1725,23 @@ open class OfflineWarmup( ?: return@withTransaction false require(current.messageId == id) if (current.revision != revision || current.revision == Long.MAX_VALUE || !current.canTransition( - phase + phase, ) || (phase == LiveLocationPhase.ACTIVE && current.expired( - System.currentTimeMillis() / 1000 + System.currentTimeMillis() / 1000, )) - ) return@withTransaction false + ) { + return@withTransaction false + } database.metaDao().upsert( MetaEntity( key, - encodeLiveLocation(current.copy(phase = phase, revision = revision + 1)) - ) + encodeLiveLocation( + current.copy( + phase = phase, + revision = revision + 1, + ), + ), + ), ) true } @@ -1419,38 +1765,45 @@ open class OfflineWarmup( check(current.revision < Long.MAX_VALUE) database.metaDao().upsert( MetaEntity( - key, encodeLiveLocation( + key, + encodeLiveLocation( current.copy( - phase = phase, revision = current.revision + 1 - ) - ) - ) + phase = phase, + revision = current.revision + 1, + ), + ), + ), ) } } } } - open suspend fun blockScheduledQueueUntil(deadline: Long, expectedEpoch: Long) = - draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - blockOutgoingSendsUntil(deadline) - } + open suspend fun blockScheduledQueueUntil( + deadline: Long, + expectedEpoch: Long, + ) = draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + blockOutgoingSendsUntil(deadline) + } open fun observeScheduledMessages(chatId: PeerId): Flow> = db?.scheduledMessageDao()?.observe(chatId.value)?.map { rows -> rows.map { ScheduledMessage( - it.message.toModel(), it.hasUnsupportedContent + it.message.toModel(), + it.hasUnsupportedContent, ) } } ?: flowOf(emptyList()) - open suspend fun scheduledQueueRevision(chatId: PeerId, expectedEpoch: Long): Long = - draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - roomIo { checkNotNull(db).scheduledMessageDao().revision(chatId.value) } - } + open suspend fun scheduledQueueRevision( + chatId: PeerId, + expectedEpoch: Long, + ): Long = draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + roomIo { checkNotNull(db).scheduledMessageDao().revision(chatId.value) } + } open suspend fun replaceScheduledMessagesIfCurrent( chatId: PeerId, @@ -1461,26 +1814,35 @@ open class OfflineWarmup( check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) roomIo { checkNotNull(db).scheduledMessageDao().replaceIfCurrent( - chatId.value, expectedRevision, messages.map { + chatId.value, + expectedRevision, + messages.map { ScheduledMessageEntity( - it.message.toEntity(), it.hasUnsupportedContent + it.message.toEntity(), + it.hasUnsupportedContent, ) - }) + }, + ) } } open suspend fun applyScheduledMessage(message: ScheduledMessage) = roomIo { db?.scheduledMessageDao()?.applyMessages( - message.message.id.chatId.value, listOf( + message.message.id.chatId.value, + listOf( ScheduledMessageEntity( - message.message.toEntity(), message.hasUnsupportedContent - ) - ) + message.message.toEntity(), + message.hasUnsupportedContent, + ), + ), ) Unit } - open suspend fun deleteScheduledMessages(chatId: PeerId, ids: List) = roomIo { + open suspend fun deleteScheduledMessages( + chatId: PeerId, + ids: List, + ) = roomIo { db?.scheduledMessageDao()?.remove(chatId.value, ids) Unit } @@ -1511,7 +1873,7 @@ open class OfflineWarmup( database.profileMediaDao().clear() database.profileCommonDao().clear() database.messageHoleDao().clearAll() - database.metaDao().deleteLike("history_coverage:%") + database.metaDao().deletePrefix("history_coverage:") database.metaDao().deleteLike("discussion_read:%") database.metaDao().deleteLike("${DRAFT_META_PREFIX}%") database.metaDao().deleteLike("cloud_draft:%") @@ -1529,11 +1891,17 @@ open class OfflineWarmup( } } - open suspend fun setDialogScroll(chatId: PeerId, messageId: Int) = roomIo { + open suspend fun setDialogScroll( + chatId: PeerId, + messageId: Int, + ) = roomIo { db?.chatDao()?.updateDialogScroll(chatId.value, messageId) } - open suspend fun markChatRead(chatId: PeerId, maxId: Int) = roomIo { + open suspend fun markChatRead( + chatId: PeerId, + maxId: Int, + ) = roomIo { val database = db ?: return@roomIo database.withTransaction { val stored = database.chatDao().get(chatId.value) ?: return@withTransaction @@ -1543,7 +1911,11 @@ open class OfflineWarmup( } } - open suspend fun applyInboxRead(chatId: PeerId, maxId: Int, stillUnread: Int) = roomIo { + open suspend fun applyInboxRead( + chatId: PeerId, + maxId: Int, + stillUnread: Int, + ) = roomIo { val database = db ?: return@roomIo database.withTransaction { if (database.chatDao().get(chatId.value) == null) { @@ -1571,14 +1943,22 @@ data class HistoryPrune( /** Telegram history `limit` ceiling; Room cache windows stay at or below this. */ const val HISTORY_CACHE_WINDOW = 100 -fun staleHistoryIds(cachedIds: List, lastMessageId: Int): List = - if (lastMessageId <= 0) emptyList() else cachedIds.filter { it > lastMessageId } +fun staleHistoryIds( + cachedIds: List, + lastMessageId: Int, +): List = if (lastMessageId <= 0) emptyList() else cachedIds.filter { it > lastMessageId } /** Holes inside a fetched page, never live rows newer than that page. */ -fun shouldDropUnsentAfterRestart(pending: Boolean, id: Int, randomId: Long?): Boolean = - (pending || id < 0) && (randomId == null || randomId == 0L) - -fun staleIdsInsideFetchedWindow(cachedIds: List, fetchedIds: Collection): List { +fun shouldDropUnsentAfterRestart( + pending: Boolean, + id: Int, + randomId: Long?, +): Boolean = (pending || id < 0) && (randomId == null || randomId == 0L) + +fun staleIdsInsideFetchedWindow( + cachedIds: List, + fetchedIds: Collection, +): List { if (fetchedIds.isEmpty()) return emptyList() val keep = fetchedIds.toSet() val minId = keep.minOrNull() ?: return emptyList() @@ -1596,6 +1976,7 @@ const val MESSAGE_BATCH_BYTES = 256 * 1024 fun Message.estimatedSerializedBytes(): Int { var bytes = 128 + fun add(value: String?) { if (value != null) bytes += value.length * 2 } @@ -1637,5 +2018,8 @@ fun chunkMessages( const val DRAFT_META_PREFIX = "draft:" -fun draftMetaKey(chatId: Long, threadId: Int = 0): String = +fun draftMetaKey( + chatId: Long, + threadId: Int = 0, +): String = if (threadId > 0) "${DRAFT_META_PREFIX}$chatId:$threadId" else "${DRAFT_META_PREFIX}$chatId" diff --git a/core/database/src/main/java/org/monogram/core/database/SessionMetadataStore.kt b/core/database/src/main/java/org/monogram/core/database/SessionMetadataStore.kt index 86deaaf4d..2444a0cbd 100644 --- a/core/database/src/main/java/org/monogram/core/database/SessionMetadataStore.kt +++ b/core/database/src/main/java/org/monogram/core/database/SessionMetadataStore.kt @@ -28,54 +28,75 @@ open class SessionMetadataStore( ) { open suspend fun saveAuthorized(session: AuthSession) { val database = db ?: return - database.metaDao().upsert(MetaEntity(KEY_USER_ID, session.userId.value.toString())) - database.metaDao().upsert(MetaEntity(KEY_DC_ID, session.dcId.toString())) - database.metaDao().upsert(MetaEntity(KEY_AUTHORIZED, "1")) + database.withTransaction { + database.metaDao().upsert(MetaEntity(KEY_USER_ID, session.userId.value.toString())) + database.metaDao().upsert(MetaEntity(KEY_DC_ID, session.dcId.toString())) + database.metaDao().upsert(MetaEntity(KEY_AUTHORIZED, "1")) + } + } + + open suspend fun recoverAuthorizedIfAbsent(session: AuthSession): Boolean { + val database = db ?: return false + return database.withTransaction { + val meta = database.metaDao() + if (meta.get(KEY_AUTHORIZED)?.value == "0" || meta.get(KEY_USER_ID) != null || meta.get( + KEY_DC_ID + ) != null + ) { + false + } else { + meta.upsert(MetaEntity(KEY_USER_ID, session.userId.value.toString())) + meta.upsert(MetaEntity(KEY_DC_ID, session.dcId.toString())) + meta.upsert(MetaEntity(KEY_AUTHORIZED, "1")) + true + } + } } open suspend fun savePremium(premium: Boolean) { db?.metaDao()?.upsert(MetaEntity(KEY_PREMIUM, if (premium) "1" else "0")) } - open suspend fun readPremium(): Boolean = - db?.metaDao()?.get(KEY_PREMIUM)?.value == "1" + open suspend fun readPremium(): Boolean = db?.metaDao()?.get(KEY_PREMIUM)?.value == "1" open suspend fun clearAuthorized() { val database = db ?: return - database.metaDao().upsert(MetaEntity(KEY_AUTHORIZED, "0")) - database.metaDao().upsert(MetaEntity(KEY_USER_ID, "")) - database.metaDao().upsert(MetaEntity(KEY_DC_ID, "")) - database.metaDao().upsert(MetaEntity(KEY_PREMIUM, "0")) + database.withTransaction { + database.metaDao().upsert(MetaEntity(KEY_AUTHORIZED, "0")) + database.metaDao().upsert(MetaEntity(KEY_USER_ID, "")) + database.metaDao().upsert(MetaEntity(KEY_DC_ID, "")) + database.metaDao().upsert(MetaEntity(KEY_PREMIUM, "0")) + } } open suspend fun clearSession() { val database = db ?: return - database.metaDao().clear() - database.updateCursorDao().clear() - database.updateCursorDao().clearReceipts() - database.profileTabsDao().clear() - database.profileMembersDao().clear() - database.profileMediaDao().clear() - database.profileCommonDao().clear() + database.withTransaction { + database.metaDao().clear() + database.metaDao().upsert(MetaEntity(KEY_AUTHORIZED, "0")) + database.updateCursorDao().clear() + database.updateCursorDao().clearReceipts() + database.profileTabsDao().clear() + database.profileMembersDao().clear() + database.profileMediaDao().clear() + database.profileCommonDao().clear() + } } open suspend fun readAuthorizedUserId(): PeerId? = db?.metaDao()?.get(KEY_USER_ID)?.value?.toLongOrNull()?.let(::PeerId) - open suspend fun isAuthorized(): Boolean = - db?.metaDao()?.get(KEY_AUTHORIZED)?.value == "1" + open suspend fun isAuthorized(): Boolean = db?.metaDao()?.get(KEY_AUTHORIZED)?.value == "1" /** Blocking startup read; call from IO, not the UI hot path. */ - fun isAuthorizedBlocking(): Boolean = - runBlocking(Dispatchers.IO) { isAuthorized() } + fun isAuthorizedBlocking(): Boolean = runBlocking(Dispatchers.IO) { isAuthorized() } open suspend fun upsertPeersFromChats(chats: List) { val database = db ?: return if (chats.isEmpty()) return withContext(Dispatchers.IO) { database.withTransaction { - val existing = database.peerDao() - .getByIds(chats.map { it.id.value }.distinct()) + val existing = database.peerDao().getByIds(chats.map { it.id.value }.distinct()) .associateBy { it.id } val peers = chats.map { chat -> val kind = when { @@ -90,10 +111,13 @@ open class SessionMetadataStore( title = preferredPeerTitle(chat.title, prior?.title, chat.id.value), username = prior?.username, about = prior?.about, - avatarCacheKey = chat.photoCacheKey ?: prior?.avatarCacheKey, + avatarCacheKey = if (chat.photoKnown) chat.photoCacheKey else chat.photoCacheKey + ?: prior?.avatarCacheKey, + avatarKnown = chat.photoKnown || chat.photoCacheKey != null || prior?.avatarKnown == true, status = chat.peerStatus ?: prior?.status, statusAt = chat.peerStatusAt ?: prior?.statusAt, - emojiStatusDocumentId = chat.emojiStatusDocumentId ?: prior?.emojiStatusDocumentId, + emojiStatusDocumentId = chat.emojiStatusDocumentId + ?: prior?.emojiStatusDocumentId, extraJson = prior?.extraJson, isSelf = prior?.isSelf ?: false, ) @@ -127,13 +151,14 @@ open class SessionMetadataStore( if (names.isEmpty()) return withContext(Dispatchers.IO) { database.withTransaction { - val existing = database.peerDao().getByIds(names.keys.toList()).associateBy { it.id } + val existing = + database.peerDao().getByIds(names.keys.toList()).associateBy { it.id } val peers = names.mapNotNull { (id, name) -> val prior = existing[id] if (prior?.kind == "channel" || prior?.kind == "group" || prior?.kind == "chat") { - return@mapNotNull if ( - isPlaceholderPeerTitle(prior.title, id) && - !isPlaceholderPeerTitle(name, id) + return@mapNotNull if (isPlaceholderPeerTitle( + prior.title, id + ) && !isPlaceholderPeerTitle(name, id) ) { prior.copy( title = name, @@ -170,8 +195,7 @@ open class SessionMetadataStore( } } - open suspend fun readProfile(peerId: Long): Profile? = - db?.peerDao()?.get(peerId)?.toProfile() + open suspend fun readProfile(peerId: Long): Profile? = db?.peerDao()?.get(peerId)?.toProfile() /** Cached shared-media counts for the profile tab strip. */ open suspend fun readProfileTabCounts(peerId: Long): ProfileTabCounts? = @@ -223,8 +247,7 @@ open class SessionMetadataStore( open suspend fun readProfileMedia(peerId: Long): Map> { val database = db ?: return emptyMap() return withContext(Dispatchers.IO) { - database.profileMediaDao().list(peerId) - .groupBy { ProfileTab.fromWire(it.tab) } + database.profileMediaDao().list(peerId).groupBy { ProfileTab.fromWire(it.tab) } .mapNotNull { (tab, rows) -> tab?.let { it to rows.map { row -> row.toMessage() } } } .toMap() } @@ -291,8 +314,7 @@ open class SessionMetadataStore( open suspend fun readUpdatesCursor(): UpdateCursorEntity? = db?.updateCursorDao()?.get(KEY_PRIMARY_CURSOR) - open suspend fun readMeta(key: String): String? = - db?.metaDao()?.get(key)?.value + open suspend fun readMeta(key: String): String? = db?.metaDao()?.get(key)?.value open suspend fun writeMeta(key: String, value: String) { db?.metaDao()?.upsert(MetaEntity(key, value)) diff --git a/core/database/src/main/java/org/monogram/core/database/dao/ChatDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/ChatDao.kt index ffd220633..cb76d40ec 100644 --- a/core/database/src/main/java/org/monogram/core/database/dao/ChatDao.kt +++ b/core/database/src/main/java/org/monogram/core/database/dao/ChatDao.kt @@ -17,27 +17,53 @@ data class ChatReadState( @Dao interface ChatDao { - @Query("UPDATE chats SET readOutboxMaxId = MAX(readOutboxMaxId, :maxId) WHERE id = :chatId") - suspend fun updateOutboxRead(chatId: Long, maxId: Int) + @Query("UPDATE chats SET readOutboxMaxId = :maxId WHERE id = :chatId AND readOutboxMaxId < :maxId") + suspend fun updateOutboxRead( + chatId: Long, + maxId: Int, + ) + @Query( "SELECT id, readInboxMaxId, unreadCount, unreadMentionsCount, unreadReactionsCount FROM chats", ) fun observeReadStates(): Flow> - @Query("UPDATE chats SET readInboxMaxId = :maxId, unreadCount = :unread WHERE id = :chatId AND readInboxMaxId <= :maxId") - suspend fun updateInboxRead(chatId: Long, maxId: Int, unread: Int) + @Query( + "UPDATE chats SET readInboxMaxId = :maxId, unreadCount = :unread WHERE id = :chatId AND readInboxMaxId <= :maxId AND (readInboxMaxId != :maxId OR unreadCount != :unread)", + ) + suspend fun updateInboxRead( + chatId: Long, + maxId: Int, + unread: Int, + ) - @Query("UPDATE chats SET unreadMentionsCount = :count WHERE id = :chatId") - suspend fun updateUnreadMentions(chatId: Long, count: Int) + @Query("UPDATE chats SET unreadMentionsCount = :count WHERE id = :chatId AND unreadMentionsCount != :count") + suspend fun updateUnreadMentions( + chatId: Long, + count: Int, + ) - @Query("UPDATE chats SET unreadReactionsCount = :count WHERE id = :chatId") - suspend fun updateUnreadReactions(chatId: Long, count: Int) + @Query("UPDATE chats SET unreadReactionsCount = :count WHERE id = :chatId AND unreadReactionsCount != :count") + suspend fun updateUnreadReactions( + chatId: Long, + count: Int, + ) - @Query("UPDATE chats SET unreadMentionsCount = MAX(0, unreadMentionsCount + :delta) WHERE id = :chatId") - suspend fun addUnreadMentions(chatId: Long, delta: Int) + @Query( + "UPDATE chats SET unreadMentionsCount = MAX(0, unreadMentionsCount + :delta) WHERE id = :chatId AND unreadMentionsCount != MAX(0, unreadMentionsCount + :delta)", + ) + suspend fun addUnreadMentions( + chatId: Long, + delta: Int, + ) - @Query("UPDATE chats SET unreadReactionsCount = MAX(0, unreadReactionsCount + :delta) WHERE id = :chatId") - suspend fun addUnreadReactions(chatId: Long, delta: Int) + @Query( + "UPDATE chats SET unreadReactionsCount = MAX(0, unreadReactionsCount + :delta) WHERE id = :chatId AND unreadReactionsCount != MAX(0, unreadReactionsCount + :delta)", + ) + suspend fun addUnreadReactions( + chatId: Long, + delta: Int, + ) @Query("SELECT * FROM chats ORDER BY pinned DESC, pinnedOrder ASC, lastMessageDate DESC, id DESC") suspend fun observeAll(): List @@ -50,7 +76,10 @@ interface ChatDao { LIMIT :limit OFFSET :offset """, ) - suspend fun mainListPage(limit: Int, offset: Int): List + suspend fun mainListPage( + limit: Int, + offset: Int, + ): List @Query( """ @@ -62,8 +91,13 @@ interface ChatDao { ) suspend fun archivePreview(limit: Int): List - @Query("SELECT * FROM chats WHERE lastMessageId IN (:ids) AND (:chatId IS NULL OR id = :chatId) AND (:chatId IS NOT NULL OR id > -1000000000000)") - suspend fun withLastMessageIds(chatId: Long?, ids: List): List + @Query( + "SELECT * FROM chats WHERE lastMessageId IN (:ids) AND (:chatId IS NULL OR id = :chatId) AND (:chatId IS NOT NULL OR id > -1000000000000)", + ) + suspend fun withLastMessageIds( + chatId: Long?, + ids: List, + ): List @Query("SELECT COUNT(*) FROM chats WHERE `left` = 0 AND archived = 0") suspend fun mainListCount(): Int @@ -99,7 +133,7 @@ interface ChatDao { pinnedOrder: Int, date: Long?, id: Long, - limit: Int + limit: Int, ): List @Query("SELECT * FROM chats WHERE id = :id LIMIT 1") @@ -114,12 +148,24 @@ interface ChatDao { @Query("DELETE FROM chats") suspend fun clear() - @Query("UPDATE chats SET dialogScrollMessageId = :messageId WHERE id = :chatId") - suspend fun updateDialogScroll(chatId: Long, messageId: Int) + @Query("UPDATE chats SET dialogScrollMessageId = :messageId WHERE id = :chatId AND dialogScrollMessageId IS NOT :messageId") + suspend fun updateDialogScroll( + chatId: Long, + messageId: Int, + ) - @Query("UPDATE chats SET peerStatus = :status, peerStatusAt = :at WHERE id = :chatId") - suspend fun updatePeerStatus(chatId: Long, status: String?, at: Long?) + @Query( + "UPDATE chats SET peerStatus = :status, peerStatusAt = :at WHERE id = :chatId AND (peerStatus IS NOT :status OR peerStatusAt IS NOT :at)", + ) + suspend fun updatePeerStatus( + chatId: Long, + status: String?, + at: Long?, + ) - @Query("UPDATE chats SET emojiStatusDocumentId = :documentId WHERE id = :chatId") - suspend fun updateEmojiStatus(chatId: Long, documentId: Long?) + @Query("UPDATE chats SET emojiStatusDocumentId = :documentId WHERE id = :chatId AND emojiStatusDocumentId IS NOT :documentId") + suspend fun updateEmojiStatus( + chatId: Long, + documentId: Long?, + ) } diff --git a/core/database/src/main/java/org/monogram/core/database/dao/MessageDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/MessageDao.kt index 0193b1345..c3416be44 100644 --- a/core/database/src/main/java/org/monogram/core/database/dao/MessageDao.kt +++ b/core/database/src/main/java/org/monogram/core/database/dao/MessageDao.kt @@ -30,7 +30,7 @@ interface MessageDao { suspend fun byIds(chatId: Long, ids: List): List @Query("SELECT * FROM messages WHERE chatId = :chatId AND randomId = :randomId AND (id < 0 OR pending = 1)") - abstract suspend fun pendingByRandomId(chatId: Long, randomId: Long): List + suspend fun pendingByRandomId(chatId: Long, randomId: Long): List @Query("SELECT DISTINCT chatId FROM messages WHERE failed = 1 OR ((pending = 1 OR id < 0) AND (randomId IS NULL OR randomId = 0))") suspend fun startupTransientChatIds(): List @@ -145,6 +145,12 @@ interface MessageDao { @Query("DELETE FROM messages WHERE chatId = :chatId AND pending = 0 AND id NOT IN (:keep)") suspend fun deleteExcept(chatId: Long, keep: List) + @Query("DELETE FROM messages WHERE chatId = :chatId AND pending = 0 AND id > 0") + suspend fun clearConfirmedHistory(chatId: Long) + + @Query("DELETE FROM messages WHERE pending = 0 AND id > 0") + suspend fun clearConfirmedHistory() + @Query("DELETE FROM messages") suspend fun clearAll() } diff --git a/core/database/src/main/java/org/monogram/core/database/dao/MetaDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/MetaDao.kt index a7fd96674..27527d03e 100644 --- a/core/database/src/main/java/org/monogram/core/database/dao/MetaDao.kt +++ b/core/database/src/main/java/org/monogram/core/database/dao/MetaDao.kt @@ -18,6 +18,15 @@ interface MetaDao { @Query("SELECT * FROM meta WHERE `key` LIKE :pattern") fun observeLike(pattern: String): Flow> + @Query("SELECT * FROM meta WHERE `key` LIKE 'cloud_draft:%' OR `key` LIKE 'draft:%'") + fun observeDrafts(): Flow> + + @Query("SELECT * FROM meta WHERE `key` LIKE 'cloud_draft:%' OR `key` LIKE 'draft:%'") + suspend fun drafts(): List + + @Query("SELECT * FROM meta WHERE `key` = :modernKey OR `key` = :legacyKey") + fun observeDraft(modernKey: String, legacyKey: String): Flow> + @Query("SELECT `key` FROM meta WHERE `key` LIKE :pattern") suspend fun keysLike(pattern: String): List @@ -30,6 +39,9 @@ interface MetaDao { @Query("DELETE FROM meta WHERE `key` LIKE :pattern") suspend fun deleteLike(pattern: String) + @Query("DELETE FROM meta WHERE substr(`key`, 1, length(:prefix)) = :prefix") + suspend fun deletePrefix(prefix: String) + @Query("DELETE FROM meta") suspend fun clear() } diff --git a/core/database/src/main/java/org/monogram/core/database/dao/OutgoingMediaSendDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/OutgoingMediaSendDao.kt index dcc63228f..d3d91d4bb 100644 --- a/core/database/src/main/java/org/monogram/core/database/dao/OutgoingMediaSendDao.kt +++ b/core/database/src/main/java/org/monogram/core/database/dao/OutgoingMediaSendDao.kt @@ -24,6 +24,9 @@ abstract class OutgoingMediaSendDao { @Query("SELECT * FROM outgoing_media_batches WHERE batchId = :batchId") abstract suspend fun get(batchId: Long): OutgoingMediaSend? + @Query("SELECT EXISTS(SELECT 1 FROM outgoing_media_batches WHERE batchId = :batchId)") + abstract suspend fun exists(batchId: Long): Boolean + @Query("SELECT * FROM outgoing_media_items WHERE randomId = :randomId") abstract suspend fun item(randomId: Long): OutgoingMediaItemEntity? diff --git a/core/database/src/main/java/org/monogram/core/database/dao/OutgoingTextSendDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/OutgoingTextSendDao.kt index 15e5ba77b..eaf6f9356 100644 --- a/core/database/src/main/java/org/monogram/core/database/dao/OutgoingTextSendDao.kt +++ b/core/database/src/main/java/org/monogram/core/database/dao/OutgoingTextSendDao.kt @@ -23,6 +23,9 @@ abstract class OutgoingTextSendDao { @Query("SELECT * FROM outgoing_text_sends WHERE randomId = :randomId") abstract suspend fun get(randomId: Long): OutgoingTextSendEntity? + @Query("SELECT EXISTS(SELECT 1 FROM outgoing_text_sends WHERE randomId = :randomId)") + abstract suspend fun exists(randomId: Long): Boolean + @Insert(onConflict = OnConflictStrategy.ABORT) protected abstract suspend fun insert(row: OutgoingTextSendEntity) diff --git a/core/database/src/main/java/org/monogram/core/database/entity/ChatEntity.kt b/core/database/src/main/java/org/monogram/core/database/entity/ChatEntity.kt index 0605310af..90da9a2c4 100644 --- a/core/database/src/main/java/org/monogram/core/database/entity/ChatEntity.kt +++ b/core/database/src/main/java/org/monogram/core/database/entity/ChatEntity.kt @@ -1,5 +1,6 @@ package org.monogram.core.database.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -48,9 +49,10 @@ data class ChatEntity( val canView: Boolean = true, val canSendPlain: Boolean = true, val canSendPhotos: Boolean = true, - @androidx.room.ColumnInfo(defaultValue = "0") val canSendPolls: Boolean = false, + @ColumnInfo(defaultValue = "0") val canSendPolls: Boolean = false, val canForward: Boolean = true, val canDeleteOthers: Boolean = false, val canManageTopics: Boolean = false, val emojiStatusDocumentId: Long? = null, + @ColumnInfo(defaultValue = "0") val photoKnown: Boolean = false, ) diff --git a/core/database/src/main/java/org/monogram/core/database/entity/PeerEntity.kt b/core/database/src/main/java/org/monogram/core/database/entity/PeerEntity.kt index 0f4a2b852..b51ebb5bd 100644 --- a/core/database/src/main/java/org/monogram/core/database/entity/PeerEntity.kt +++ b/core/database/src/main/java/org/monogram/core/database/entity/PeerEntity.kt @@ -1,5 +1,6 @@ package org.monogram.core.database.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -16,4 +17,5 @@ data class PeerEntity( val emojiStatusDocumentId: Long? = null, val extraJson: String? = null, val isSelf: Boolean = false, + @ColumnInfo(defaultValue = "0") val avatarKnown: Boolean = false, ) diff --git a/core/database/src/test/java/org/monogram/core/database/MessageMediaFieldsTest.kt b/core/database/src/test/java/org/monogram/core/database/MessageMediaFieldsTest.kt index 9aa6478a4..13cb91303 100644 --- a/core/database/src/test/java/org/monogram/core/database/MessageMediaFieldsTest.kt +++ b/core/database/src/test/java/org/monogram/core/database/MessageMediaFieldsTest.kt @@ -20,7 +20,7 @@ import java.util.concurrent.atomic.AtomicReference class MessageMediaFieldsTest { @Test fun schemaVersionIsCurrent() { - assertEquals(10, DatabaseProvider.SCHEMA_VERSION) + assertEquals(11, DatabaseProvider.SCHEMA_VERSION) } @Test @@ -53,40 +53,49 @@ class MessageMediaFieldsTest { @Test fun messageRoundtripsDurationAndSize() { - val message = Message( - id = MessageId(PeerId(42), 7), - senderId = PeerId(1), - text = null, - date = 1L, - editDate = 2L, - outgoing = false, - pending = true, - randomId = 55L, - mediaCacheKey = "doc:1", - mediaKind = "video", - thumbCacheKey = "doc:1:thumb", - mediaDuration = 12, - mediaWidth = 1280, - mediaHeight = 720, - fileSize = 8_000_000, - supportsStreaming = true, - replyQuote = "bold", - replyQuoteEntities = listOf(TextEntity("bold", 0, 4)), - replyToMsgId = 3, - replyToTopId = 42, - forumTopic = true, - fwdFrom = "Alice", - fwdFromId = 100L, - fwdDate = 1_700_000_000L, - viaBot = "gif", - senderName = "Bob", - replyMarkup = ReplyMarkup( - kind = ReplyMarkupKind.Inline, - rows = listOf( - listOf(ReplyButton(ReplyButtonType.Url, "Open", url = "https://t.me")), - ), - ), - ) + val message = + Message( + id = MessageId(PeerId(42), 7), + senderId = PeerId(1), + text = null, + date = 1L, + editDate = 2L, + outgoing = false, + pending = true, + randomId = 55L, + mediaCacheKey = "doc:1", + mediaKind = "video", + thumbCacheKey = "doc:1:thumb", + mediaDuration = 12, + mediaWidth = 1280, + mediaHeight = 720, + fileSize = 8_000_000, + supportsStreaming = true, + replyQuote = "bold", + replyQuoteEntities = listOf(TextEntity("bold", 0, 4)), + replyToMsgId = 3, + replyToTopId = 42, + forumTopic = true, + fwdFrom = "Alice", + fwdFromId = 100L, + fwdDate = 1_700_000_000L, + viaBot = "gif", + senderName = "Bob", + replyMarkup = + ReplyMarkup( + kind = ReplyMarkupKind.Inline, + rows = + listOf( + listOf( + ReplyButton( + ReplyButtonType.Url, + "Open", + url = "https://t.me" + ) + ), + ), + ), + ) val entity = message.toEntity() assertEquals(12, entity.mediaDuration) assertEquals(1280, entity.mediaWidth) @@ -109,44 +118,57 @@ class MessageMediaFieldsTest { assertEquals("Bob", back.senderName) assertEquals(2L, back.editDate) assertEquals(ReplyMarkupKind.Inline, back.replyMarkup?.kind) - assertEquals("https://t.me", back.replyMarkup?.rows?.single()?.single()?.url) + assertEquals( + "https://t.me", + back.replyMarkup + ?.rows + ?.single() + ?.single() + ?.url, + ) assertEquals(true, back.pending) assertEquals(55L, back.randomId) assertEquals(true, back.supportsStreaming) assertEquals(8_000_000L, back.fileSize) - val richChecklist = org.monogram.core.models.Checklist( - "😀 task", othersCanComplete = true, - entities = listOf(TextEntity("bold", 3, 4)), items = listOf( - org.monogram.core.models.ChecklistItem( - -7, - "😀 task", - done = true, - completedBy = 42, - completedAt = 9, - entities = listOf(TextEntity("italic", 3, 4)) - ) + val richChecklist = + org.monogram.core.models.Checklist( + "😀 task", + othersCanComplete = true, + entities = listOf(TextEntity("bold", 3, 4)), + items = + listOf( + org.monogram.core.models.ChecklistItem( + -7, + "😀 task", + done = true, + completedBy = 42, + completedAt = 9, + entities = listOf(TextEntity("italic", 3, 4)), + ), + ), + ) + val todo = + Message( + MessageId(PeerId(42), 8), + null, + null, + 2, + outgoing = false, + mediaKind = "todo", + checklist = richChecklist, ) - ) - val todo = Message( - MessageId(PeerId(42), 8), - null, - null, - 2, - outgoing = false, - mediaKind = "todo", - checklist = richChecklist - ) assertEquals(richChecklist, todo.toEntity().toModel().checklist) } @Test fun chatRoundtripsUnreadMentionAndReactionCounts() { - val chat = org.monogram.core.models.Chat( - id = PeerId(9), - title = "Ada", - unreadMentionsCount = 3, - unreadReactionsCount = 2, - ) + val chat = + org.monogram.core.models.Chat( + id = PeerId(9), + title = "Ada", + unreadMentionsCount = 3, + unreadReactionsCount = 2, + ) val back = chat.toEntity().toModel() assertEquals(3, back.unreadMentionsCount) assertEquals(2, back.unreadReactionsCount) @@ -154,16 +176,17 @@ class MessageMediaFieldsTest { @Test fun entityDefaultsKeepNullMetrics() { - val entity = MessageEntity( - chatId = 1L, - id = 2, - senderId = null, - text = "hi", - date = 0L, - outgoing = true, - mediaCacheKey = null, - mediaKind = null, - ) + val entity = + MessageEntity( + chatId = 1L, + id = 2, + senderId = null, + text = "hi", + date = 0L, + outgoing = true, + mediaCacheKey = null, + mediaKind = null, + ) val model = entity.toModel() assertEquals(null, model.mediaDuration) assertEquals(null, model.mediaWidth) diff --git a/core/database/src/test/java/org/monogram/core/database/OfflineWarmupCleanupTest.kt b/core/database/src/test/java/org/monogram/core/database/OfflineWarmupCleanupTest.kt index 9c68fd7a6..58af2f4ed 100644 --- a/core/database/src/test/java/org/monogram/core/database/OfflineWarmupCleanupTest.kt +++ b/core/database/src/test/java/org/monogram/core/database/OfflineWarmupCleanupTest.kt @@ -12,6 +12,14 @@ import org.monogram.core.models.PeerId import org.monogram.core.models.PeerId as ChatId class OfflineWarmupCleanupTest { + @Test + fun durableBatchWithoutDatabaseCannotBeAcknowledged() = runBlocking { + var applied = false + val committed = OfflineWarmup().applyDurableBatch(1, 1, null, emptyMap()) { applied = true } + assertEquals(false, committed) + assertEquals(false, applied) + } + private class CountingWarmup : OfflineWarmup(db = null) { var drops = 0 diff --git a/core/models/src/main/java/org/monogram/core/models/ChatIncomingMerge.kt b/core/models/src/main/java/org/monogram/core/models/ChatIncomingMerge.kt index 562ae4d96..ccdb91670 100644 --- a/core/models/src/main/java/org/monogram/core/models/ChatIncomingMerge.kt +++ b/core/models/src/main/java/org/monogram/core/models/ChatIncomingMerge.kt @@ -9,7 +9,11 @@ import java.util.concurrent.ConcurrentHashMap object IncomingOrder { private val applied = ConcurrentHashMap>() - fun accept(chatId: Long, accountGeneration: Long, acceptedOrder: Long): Boolean { + fun accept( + chatId: Long, + accountGeneration: Long, + acceptedOrder: Long, + ): Boolean { if (accountGeneration == 0L) return true val previous = applied[chatId] if (previous != null) { @@ -39,13 +43,13 @@ fun Chat.mergeIncomingMessage( if (!IncomingOrder.accept( message.id.chatId.value, accountGeneration, - acceptedOrder + acceptedOrder, ) - ) return this + ) { + return this + } val unread = - if (alreadyStored || message.outgoing || message.id.id <= readInboxMaxId || - message.id.id == lastMessageId - ) { + if (alreadyStored || message.outgoing || message.id.id <= readInboxMaxId || message.id.id == lastMessageId) { unreadCount } else { unreadCount + 1 @@ -59,6 +63,6 @@ fun Chat.mergeIncomingMessage( lastMessageDate = message.date, unreadCount = unread, lastMessageId = message.id.id, - lastMediaThumbCacheKey = message.thumbCacheKey ?: lastMediaThumbCacheKey, + lastMediaThumbCacheKey = message.thumbCacheKey, ) } diff --git a/core/models/src/main/java/org/monogram/core/models/Models.kt b/core/models/src/main/java/org/monogram/core/models/Models.kt index 320a73cad..23ac4e736 100644 --- a/core/models/src/main/java/org/monogram/core/models/Models.kt +++ b/core/models/src/main/java/org/monogram/core/models/Models.kt @@ -70,6 +70,8 @@ data class Chat( val canManageTopics: Boolean = false, val emojiStatusDocumentId: Long? = null, val isVerified: Boolean = false, + /** True when null photo metadata explicitly means the avatar was removed. */ + val photoKnown: Boolean = false, ) fun isPlaceholderPeerTitle(title: String, peerId: Long): Boolean { @@ -77,15 +79,13 @@ fun isPlaceholderPeerTitle(title: String, peerId: Long): Boolean { if (t.isEmpty()) return true val signed = peerId.toString() val abs = kotlin.math.abs(peerId).toString() - return t.equals("Chat", true) || - t.equals("Channel", true) || - t.equals("User", true) || - t.equals("Chat $signed", true) || - t.equals("Chat $abs", true) || - t.equals("Channel $signed", true) || - t.equals("Channel $abs", true) || - t.equals("User $signed", true) || - t.equals("User $abs", true) + return t.equals("Chat", true) || t.equals("Channel", true) || t.equals( + "User", true + ) || t.equals("Chat $signed", true) || t.equals( + "Chat $abs", true + ) || t.equals("Channel $signed", true) || t.equals( + "Channel $abs", true + ) || t.equals("User $signed", true) || t.equals("User $abs", true) } fun preferredPeerTitle(incoming: String, previous: String?, peerId: Long): String { @@ -115,7 +115,8 @@ fun Chat.mergeLocalCache(stored: Chat?): Chat { pinned && stored?.pinned == true && pinnedOrder == Int.MAX_VALUE -> stored.pinnedOrder else -> pinnedOrder }, - photoCacheKey = photoCacheKey ?: stored?.photoCacheKey, + photoCacheKey = if (photoKnown) photoCacheKey else photoCacheKey ?: stored?.photoCacheKey, + photoKnown = photoKnown || photoCacheKey != null || stored?.photoKnown == true, emojiStatusDocumentId = emojiStatusDocumentId ?: stored?.emojiStatusDocumentId, readInboxMaxId = maxOf(readInboxMaxId, stored?.readInboxMaxId ?: 0), readOutboxMaxId = maxOf(readOutboxMaxId, stored?.readOutboxMaxId ?: 0), @@ -137,8 +138,8 @@ fun Chat.mergeLocalCache(stored: Chat?): Chat { lastMessageOutgoing = when { newerStored != null -> newerStored.lastMessageOutgoing lastMessageOutgoing -> true - stored != null && lastMessageId != 0 && lastMessageId == stored.lastMessageId -> - stored.lastMessageOutgoing + stored != null && lastMessageId != 0 && lastMessageId == stored.lastMessageId -> stored.lastMessageOutgoing + else -> false }, lastMessageMediaKind = if (newerStored != null) { @@ -254,10 +255,8 @@ fun Chat.historySeed(): Message? { senderName = sender ?: lastMessageSenderName, ) // A group chat-list line we could not unwrap is not a real message body. - if (group && - sender == null && - text == lastMessagePreview && - lastMessagePreview.orEmpty().contains(':') + if (group && sender == null && text == lastMessagePreview && lastMessagePreview.orEmpty() + .contains(':') ) { return null } @@ -279,11 +278,7 @@ fun List.replaceComposedDialogSeed(chat: Chat?): List { val preview = target.lastMessagePreview ?: return this var changed = false val out = mapNotNull { message -> - if (message.id.id != target.lastMessageId || - message.senderId != null || - message.entities.isNotEmpty() || - message.text != preview - ) { + if (message.id.id != target.lastMessageId || message.senderId != null || message.entities.isNotEmpty() || message.text != preview) { return@mapNotNull message } changed = true @@ -341,6 +336,7 @@ data class Profile( val canViewParticipants: Boolean? = null, /** `userFull.blocked`: this account blocked the user. */ val blockedByMe: Boolean = false, + val avatarKnown: Boolean = false, ) data class StickerPack( @@ -405,10 +401,8 @@ data class InlineBotResult( ) { /** Indexed as `(documentId, 0)` or `(photoId, 0)` in the native media map. */ fun previewLookupId(): Long? = - documentId ?: thumbCacheKey - ?.substringAfter("photo:", missingDelimiterValue = "") - ?.substringBefore(':') - ?.toLongOrNull() + documentId ?: thumbCacheKey?.substringAfter("photo:", missingDelimiterValue = "") + ?.substringBefore(':')?.toLongOrNull() } data class InlineBotResults( @@ -441,6 +435,7 @@ sealed class AuthState { val codeType: String = "", val codeLength: Int = 5, ) : AuthState() + data object AwaitingPassword : AuthState() data class Authorized(val session: AuthSession) : AuthState() } diff --git a/core/ui/src/androidTest/java/org/monogram/core/ui/AccountImageCacheTest.kt b/core/ui/src/androidTest/java/org/monogram/core/ui/AccountImageCacheTest.kt new file mode 100644 index 000000000..01dbc7943 --- /dev/null +++ b/core/ui/src/androidTest/java/org/monogram/core/ui/AccountImageCacheTest.kt @@ -0,0 +1,57 @@ +package org.monogram.core.ui + +import android.graphics.Bitmap +import androidx.test.platform.app.InstrumentationRegistry +import coil.memory.MemoryCache +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import java.io.File +import java.util.UUID + +@OptIn(coil.annotation.ExperimentalCoilApi::class) +class AccountImageCacheTest { + @Test + fun identicalKeysAndClearingRemainAccountLocal() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val directory = File(context.cacheDir, "image-accounts-${UUID.randomUUID()}") + val firstRoot = File(directory, "11/media") + val secondRoot = File(directory, "22/media") + val first = ImageCache.accountLoader(context, firstRoot) + val second = ImageCache.accountLoader(context, secondRoot) + val key = MemoryCache.Key("same-peer-photo") + val firstBitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888) + val secondBitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888) + try { + assertNotSame(first, second) + assertSame(first, ImageCache.accountLoader(context, firstRoot)) + assertNotEquals(first.diskCache!!.directory, second.diskCache!!.directory) + first.memoryCache!![key] = MemoryCache.Value(firstBitmap) + second.memoryCache!![key] = MemoryCache.Value(secondBitmap) + assertSame(firstBitmap, first.memoryCache!![key]?.bitmap) + assertSame(secondBitmap, second.memoryCache!![key]?.bitmap) + for (loader in listOf(first, second)) { + loader.diskCache!!.openEditor("same-peer-photo")!!.let { editor -> + File(editor.data.toString()).writeText("cached") + editor.commit() + } + } + ImageCache.clear(firstRoot) + assertNull(first.memoryCache!![key]) + assertSame(secondBitmap, second.memoryCache!![key]?.bitmap) + assertNull(first.diskCache!!.openSnapshot("same-peer-photo")) + second.diskCache!!.openSnapshot("same-peer-photo").use { assertNotNull(it) } + } finally { + val firstDisk = File(first.diskCache!!.directory.toString()) + val secondDisk = File(second.diskCache!!.directory.toString()) + ImageCache.release(firstRoot) + ImageCache.release(secondRoot) + firstDisk.deleteRecursively() + secondDisk.deleteRecursively() + directory.deleteRecursively() + } + } +} diff --git a/core/ui/src/main/java/org/monogram/core/ui/CurrentLocation.kt b/core/ui/src/main/java/org/monogram/core/ui/CurrentLocation.kt index 5ae135348..2fcd4fd78 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/CurrentLocation.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/CurrentLocation.kt @@ -19,7 +19,7 @@ import java.util.concurrent.atomic.AtomicBoolean private fun locationPermitted(context: Context) = context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED || context.checkSelfPermission( - Manifest.permission.ACCESS_FINE_LOCATION + Manifest.permission.ACCESS_FINE_LOCATION, ) == PackageManager.PERMISSION_GRANTED private fun locationManager(context: Context) = @@ -28,13 +28,15 @@ private fun locationManager(context: Context) = private fun locationProviders(manager: LocationManager): List = linkedSetOf().apply { addAll(manager.getProviders(true)) - add(LocationManager.GPS_PROVIDER); add(LocationManager.NETWORK_PROVIDER); add( - LocationManager.PASSIVE_PROVIDER - ) + add(LocationManager.GPS_PROVIDER) + add(LocationManager.NETWORK_PROVIDER) + add( + LocationManager.PASSIVE_PROVIDER, + ) if (Build.VERSION.SDK_INT >= 31) add(LocationManager.FUSED_PROVIDER) }.filter { it == LocationManager.PASSIVE_PROVIDER || (Build.VERSION.SDK_INT >= 31 && it == LocationManager.FUSED_PROVIDER) || manager.isProviderEnabled( - it + it, ) } @@ -53,7 +55,9 @@ fun lastKnownLocation(context: Context): Pair? { @Suppress("MissingPermission") suspend fun currentLocation( - context: Context, timeoutMs: Long = 8_000, allowCached: Boolean = true + context: Context, + timeoutMs: Long = 8_000, + allowCached: Boolean = true, ): Pair? = withContext(Dispatchers.IO) { if (!locationPermitted(context)) return@withContext null val manager = locationManager(context) ?: return@withContext null @@ -62,15 +66,19 @@ suspend fun currentLocation( } catch (_: SecurityException) { return@withContext null } - val cached = - if (allowCached) providers.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() } - .maxByOrNull { it.time } else null + val cached = if (allowCached) { + providers.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() } + .maxByOrNull { it.time } + } else { + null + } val fresh = coroutineScope { providers.map { provider -> async { withTimeoutOrNull(timeoutMs) { awaitSingleFix( - manager, provider + manager, + provider, ) } } @@ -82,46 +90,62 @@ suspend fun currentLocation( } @Suppress("MissingPermission") -private suspend fun awaitSingleFix(manager: LocationManager, provider: String): Location? = - suspendCancellableCoroutine { continuation -> - val settled = AtomicBoolean() - val signal = if (Build.VERSION.SDK_INT >= 30) CancellationSignal() else null - lateinit var listener: android.location.LocationListener - fun release() { - runCatching { manager.removeUpdates(listener) }; signal?.cancel() - } +private suspend fun awaitSingleFix( + manager: LocationManager, + provider: String, +): Location? = suspendCancellableCoroutine { continuation -> + val settled = AtomicBoolean() + val signal = if (Build.VERSION.SDK_INT >= 30) CancellationSignal() else null + lateinit var listener: android.location.LocationListener - fun finish(location: Location?) { - if (settled.compareAndSet(false, true)) { - release(); continuation.resumeWith(Result.success(location)) - } - } - listener = object : android.location.LocationListener { - override fun onLocationChanged(location: Location) { - finish(location) - } + fun release() { + runCatching { manager.removeUpdates(listener) } + signal?.cancel() + } - override fun onProviderDisabled(provider: String) = Unit - override fun onProviderEnabled(provider: String) = Unit - override fun onStatusChanged( - provider: String?, status: Int, extras: android.os.Bundle? - ) = Unit + fun finish(location: Location?) { + if (settled.compareAndSet(false, true)) { + release() + continuation.resumeWith(Result.success(location)) } - continuation.invokeOnCancellation { settled.set(true); release() } - try { - if (continuation.isActive) { - manager.requestLocationUpdates(provider, 0L, 0f, listener, Looper.getMainLooper()) - if (Build.VERSION.SDK_INT >= 30 && continuation.isActive && !settled.get()) manager.getCurrentLocation( + } + listener = object : android.location.LocationListener { + override fun onLocationChanged(location: Location) { + finish(location) + } + + override fun onProviderDisabled(provider: String) = Unit + + override fun onProviderEnabled(provider: String) = Unit + + @Suppress("OVERRIDE_DEPRECATION") + override fun onStatusChanged( + provider: String?, + status: Int, + extras: android.os.Bundle?, + ) = Unit + } + continuation.invokeOnCancellation { + settled.set(true) + release() + } + try { + if (continuation.isActive) { + manager.requestLocationUpdates(provider, 0L, 0f, listener, Looper.getMainLooper()) + if (Build.VERSION.SDK_INT >= 30 && continuation.isActive && !settled.get()) { + manager.getCurrentLocation( provider, signal, - { it.run() }) { location -> + { it.run() }, + ) { location -> finish( - location + location, ) } - if (!continuation.isActive || settled.get()) release() } - } catch (_: Exception) { - finish(null) + if (!continuation.isActive || settled.get()) release() } + } catch (_: Exception) { + finish(null) } +} diff --git a/core/ui/src/main/java/org/monogram/core/ui/ImageCache.kt b/core/ui/src/main/java/org/monogram/core/ui/ImageCache.kt index f806a201a..1986a7e37 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/ImageCache.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/ImageCache.kt @@ -1,10 +1,14 @@ package org.monogram.core.ui import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext import coil.Coil import coil.EventListener import coil.ImageLoader import coil.annotation.ExperimentalCoilApi +import coil.compose.LocalImageLoader import coil.decode.VideoFrameDecoder import coil.disk.DiskCache import coil.memory.MemoryCache @@ -12,9 +16,10 @@ import coil.request.ErrorResult import coil.request.ImageRequest import coil.request.SuccessResult import java.io.File +import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap -/** Process-wide Coil memory + durable disk cache for avatars, thumbs, and photos. */ +/** Coil caches are owned by the media root of each account. */ object ImageCache { const val DISK_DIR = "coil" const val MAX_DISK_BYTES = 256L * 1024L * 1024L @@ -22,25 +27,43 @@ object ImageCache { @Volatile private var loader: ImageLoader? = null + private val accountLoaders = ConcurrentHashMap() + + @Volatile + private var accountFilesDir: File? = null + + @Synchronized + internal fun accountLoader(context: Context, root: File): ImageLoader = + accountLoaders.getOrPut(root.canonicalPath) { + val app = context.applicationContext + accountFilesDir = app.filesDir + ImageLoader.Builder(app) + .memoryCache { MemoryCache.Builder(app).maxSizePercent(0.05).build() }.diskCache { + DiskCache.Builder().directory(accountDiskDir(app.filesDir, root)) + .maxSizeBytes(MAX_DISK_BYTES).build() + }.respectCacheHeaders(false).components { add(VideoFrameDecoder.Factory()) } + .eventListener(leaseListener).crossfade(false).build() + } + + @Suppress("DEPRECATION") + @Composable + fun AccountScope(root: File, content: @Composable () -> Unit) { + val loader = accountLoader(LocalContext.current, root) + CompositionLocalProvider(LocalImageLoader provides loader, content = content) + } + fun diskDir(filesDir: File): File = File(filesDir, DISK_DIR) fun install(context: Context) { val app = context.applicationContext - val created = ImageLoader.Builder(app) - .memoryCache { - MemoryCache.Builder(app).maxSizePercent(0.25).build() - } - .diskCache { - DiskCache.Builder() - .directory(diskDir(app.filesDir)) - .maxSizeBytes(MAX_DISK_BYTES) - .build() - } - .respectCacheHeaders(false) - .components { add(VideoFrameDecoder.Factory()) } - .eventListener(leaseListener) - .crossfade(false) - .build() + accountFilesDir = app.filesDir + val created = ImageLoader.Builder(app).memoryCache { + MemoryCache.Builder(app).maxSizePercent(0.25).build() + }.diskCache { + DiskCache.Builder().directory(diskDir(app.filesDir)).maxSizeBytes(MAX_DISK_BYTES) + .build() + }.respectCacheHeaders(false).components { add(VideoFrameDecoder.Factory()) } + .eventListener(leaseListener).crossfade(false).build() loader = created Coil.setImageLoader(created) } @@ -66,9 +89,29 @@ object ImageCache { } } + fun release(root: File) { + clear(root) + accountLoaders.remove(root.canonicalPath)?.shutdown() + accountDiskDir(root)?.deleteRecursively() + } + @OptIn(ExperimentalCoilApi::class) - fun clear() { - loader?.memoryCache?.clear() - loader?.diskCache?.clear() + fun clear(root: File?) { + val accountRoot = root ?: return + accountLoaders[accountRoot.canonicalPath]?.let { account -> + account.memoryCache?.clear() + account.diskCache?.clear() + } ?: accountDiskDir(accountRoot)?.deleteRecursively() + } + + private fun accountDiskDir(filesDir: File, root: File): File { + val name = MessageDigest.getInstance("SHA-256").digest(root.canonicalPath.toByteArray()) + .joinToString("") { "%02x".format(it) } + return File(filesDir, "coil-accounts/$name") + } + + private fun accountDiskDir(root: File): File? { + val filesDir = accountFilesDir ?: return null + return accountDiskDir(filesDir, root) } } diff --git a/core/ui/src/main/java/org/monogram/core/ui/components/AppModalSheet.kt b/core/ui/src/main/java/org/monogram/core/ui/components/AppModalSheet.kt index 4766385f9..35fcd37c5 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/components/AppModalSheet.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/components/AppModalSheet.kt @@ -28,13 +28,13 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -114,9 +114,10 @@ fun AppModalSheet( val density = LocalDensity.current val view = LocalView.current val windowBounds = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - (view.context.getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager) - .currentWindowMetrics.bounds - } else null + (view.context.getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager).currentWindowMetrics.bounds + } else { + null + } val overlayWidth = with(density) { (windowBounds?.width() ?: view.resources.displayMetrics.widthPixels).toDp() } @@ -135,6 +136,7 @@ fun AppModalSheet( var settleJob by remember { mutableStateOf(null) } val dismissVelocityPx = with(density) { 1000.dp.toPx() } val maxOvershootPx = with(density) { 64.dp.toPx() } + fun applyDrag(delta: Float) { settleJob?.cancel() val current = dragOffsetY @@ -144,6 +146,7 @@ fun AppModalSheet( (current + delta * OVERSHOOT_RESISTANCE).coerceAtLeast(-maxOvershootPx) } } + fun settle(velocityY: Float) { if (closing) return val height = sheetHeightPx.toFloat().coerceAtLeast(1f) @@ -219,7 +222,10 @@ fun AppModalSheet( } val nestedScroll = remember { object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { if (source != NestedScrollSource.UserInput) return Offset.Zero if (dragOffsetY > 0f && available.y < 0f) { val consumed = available.y.coerceAtLeast(-dragOffsetY) @@ -228,11 +234,13 @@ fun AppModalSheet( } return Offset.Zero } + override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset = Offset.Zero + override suspend fun onPreFling(available: Velocity): Velocity { if (dragOffsetY > 0f) { settle(available.y) @@ -240,7 +248,11 @@ fun AppModalSheet( } return Velocity.Zero } - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity, + ): Velocity { if (dragOffsetY != 0f) { settle(available.y) return available @@ -297,8 +309,9 @@ fun AppModalSheet( } else { 0f } - alpha = scrimColor.alpha * progress.value.coerceIn(0f, 1f) * - (1f - dragged) * (1f - backProgress.coerceIn(0f, 1f)) + alpha = scrimColor.alpha * progress.value.coerceIn( + 0f, 1f + ) * (1f - dragged) * (1f - backProgress.coerceIn(0f, 1f)) } .background(scrimColor.copy(alpha = 1f)) .clickable( @@ -313,9 +326,7 @@ fun AppModalSheet( .align(if (capped) Alignment.BottomEnd else Alignment.BottomCenter) .then( if (capped) { - Modifier - .padding(end = 12.dp, bottom = 8.dp) - .widthIn(max = maxWidth) + Modifier.padding(end = 12.dp, bottom = 8.dp).widthIn(max = maxWidth) } else { Modifier }, @@ -323,11 +334,10 @@ fun AppModalSheet( .fillMaxWidth() .onSizeChanged { sheetHeightPx = it.height } .graphicsLayer { - translationY = ( - (1f - progress.value) * size.height + - dragOffsetY + - backProgress * size.height - ).coerceAtLeast(0f) + translationY = + ((1f - progress.value) * size.height + dragOffsetY + backProgress * size.height).coerceAtLeast( + 0f + ) if (fadeSheet) alpha = progress.value.coerceIn(0f, 1f) } .clickable( @@ -408,12 +418,15 @@ private fun SheetPopupBack( override fun onBackStarted(backEvent: android.window.BackEvent) { progressCb.value(backEvent.progress.coerceIn(0f, 1f)) } + override fun onBackProgressed(backEvent: android.window.BackEvent) { progressCb.value(backEvent.progress.coerceIn(0f, 1f)) } + override fun onBackCancelled() { cancelCb.value() } + override fun onBackInvoked() { invokeCb.value() } @@ -457,14 +470,18 @@ private fun TransparentPopupSystemBars() { val params = root.layoutParams as? WindowManager.LayoutParams if (params != null) { // Compose handles IME insets; Android's default popup panning resets them on focus changes. - params.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE - params.flags = params.flags or WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS + @Suppress("DEPRECATION") params.softInputMode = + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + params.flags = + params.flags or WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { params.setFitInsetsTypes(0) } runCatching { - (view.context.getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager) - .updateViewLayout(root, params) + (view.context.getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager).updateViewLayout( + root, + params + ) } } var ctx = view.context @@ -472,12 +489,13 @@ private fun TransparentPopupSystemBars() { if (ctx is Activity) { val window = ctx.window WindowCompat.setDecorFitsSystemWindows(window, false) - @Suppress("DEPRECATION") - window.navigationBarColor = android.graphics.Color.TRANSPARENT + @Suppress("DEPRECATION") window.navigationBarColor = + android.graphics.Color.TRANSPARENT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { window.isNavigationBarContrastEnforced = false } - WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = lightNav + WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = + lightNav break } ctx = ctx.baseContext diff --git a/core/ui/src/main/java/org/monogram/core/ui/components/LoopingVideo.kt b/core/ui/src/main/java/org/monogram/core/ui/components/LoopingVideo.kt index 374d36615..b0b6cd9e6 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/components/LoopingVideo.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/components/LoopingVideo.kt @@ -1,5 +1,6 @@ package org.monogram.core.ui.components +import android.annotation.SuppressLint import android.graphics.Outline import android.net.Uri import android.view.TextureView @@ -18,13 +19,16 @@ import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer +import org.monogram.core.ui.rememberFileLease import java.io.File +import kotlin.collections.MutableMap.MutableEntry private val loopPositions = object : LinkedHashMap(64, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + override fun removeEldestEntry(eldest: MutableEntry?): Boolean = size > 64 } +@SuppressLint("UnsafeOptInUsageError") @Composable fun LoopingVideo( file: File, @@ -35,9 +39,10 @@ fun LoopingVideo( playWhenReady: Boolean = true, ) { val context = LocalContext.current - val playbackKey = "${file.absolutePath}:${file.length()}" + rememberFileLease(file) + val playbackKey = "${file.absolutePath}:${file.length()}:${file.lastModified()}" val uri = remember(playbackKey) { Uri.fromFile(file) } - val player = remember(uri, crop) { + val player = remember(playbackKey, crop) { ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(uri)) if (crop) { @@ -79,7 +84,10 @@ fun LoopingVideo( isFocusable = false if (crop) { outlineProvider = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { + override fun getOutline( + view: View, + outline: Outline, + ) { val size = minOf(view.width, view.height) val left = (view.width - size) / 2 val top = (view.height - size) / 2 @@ -96,6 +104,7 @@ fun LoopingVideo( if (!player.playWhenReady) player.seekTo(0) } }, + update = { view -> player.setVideoTextureView(view) }, onRelease = { view -> runCatching { player.clearVideoTextureView(view) } }, diff --git a/core/ui/src/main/java/org/monogram/core/ui/components/PeerAvatar.kt b/core/ui/src/main/java/org/monogram/core/ui/components/PeerAvatar.kt index 08a2716f5..add432fbb 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/components/PeerAvatar.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/components/PeerAvatar.kt @@ -1,5 +1,6 @@ package org.monogram.core.ui.components +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -13,15 +14,22 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil.ImageLoader import coil.compose.AsyncImage +import coil.compose.rememberAsyncImagePainter +import coil.decode.Decoder +import coil.decode.VideoFrameDecoder +import coil.fetch.SourceResult import coil.request.CachePolicy import coil.request.ImageRequest +import coil.request.Options import org.monogram.core.ui.mp4File import org.monogram.core.ui.webmFile import java.io.File @@ -31,8 +39,7 @@ import java.io.File * Titles whose words carry no letters or digits (emoji-only names) fall back to `?`. */ fun peerInitials(title: String): String { - val letters = title.trim() - .split(' ', '\t', '\n', '_', '-', ',', '.') + val letters = title.trim().split(' ', '\t', '\n', '_', '-', ',', '.') .mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() } } return when { letters.size >= 2 -> "${letters[0].uppercaseChar()}${letters[1].uppercaseChar()}" @@ -65,6 +72,34 @@ fun ServiceAvatar( } } +private object AvatarVideoDecoder : Decoder.Factory { + override fun create( + result: SourceResult, + options: Options, + imageLoader: ImageLoader, + ): Decoder = VideoFrameDecoder(result.source, options) +} + +@Composable +fun rememberAvatarVideoPoster( + file: File?, + generation: Long, +): Painter? { + val context = LocalContext.current + val video = file?.takeIf { webmFile(it) || mp4File(it) } + val modified = video?.lastModified() + val request = remember(context, video, generation, modified) { + video?.let { + ImageRequest.Builder(context).data(it) + .memoryCacheKey("avatar-poster:${it.absolutePath}:${it.length()}:$modified:$generation") + .decoderFactory(AvatarVideoDecoder).diskCachePolicy(CachePolicy.DISABLED).size(192) + .crossfade(false).build() + } + } + val painter = rememberAsyncImagePainter(request) + return painter.takeIf { request != null } +} + @Composable fun PeerAvatar( title: String, @@ -72,9 +107,12 @@ fun PeerAvatar( size: Dp = 52.dp, imageFile: File? = null, previewBytes: ByteArray? = null, + placeholder: Painter? = null, ) { val initial = remember(title) { peerInitials(title) } - val avatarBg = remember(title) { org.monogram.core.ui.ColorUtils.generateColorFromHash(title) } + val avatarBg = remember(title) { + org.monogram.core.ui.ColorUtils.generateColorFromHash(title) + } Box( modifier = modifier .size(size) @@ -90,6 +128,14 @@ fun PeerAvatar( ), color = avatarBg, ) + if (placeholder != null) { + Image( + painter = placeholder, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } val file = imageFile?.takeIf { it.exists() && it.length() > 0L } val preview = previewBytes?.takeIf { file == null && it.isNotEmpty() } if (file != null) { @@ -104,12 +150,10 @@ fun PeerAvatar( } else { val context = LocalContext.current AsyncImage( - model = ImageRequest.Builder(context) - .data(file) - .memoryCacheKey("${file.absolutePath}:${file.length()}") - .diskCacheKey("${file.absolutePath}:${file.length()}") - .crossfade(false) - .build(), + model = ImageRequest.Builder(context).data(file) + .memoryCacheKey("${file.absolutePath}:${file.length()}:${file.lastModified()}") + .diskCacheKey("${file.absolutePath}:${file.length()}:${file.lastModified()}") + .crossfade(false).build(), contentDescription = null, modifier = Modifier .fillMaxSize() @@ -120,14 +164,10 @@ fun PeerAvatar( } else if (preview != null) { val context = LocalContext.current AsyncImage( - model = ImageRequest.Builder(context) - .data(preview) + model = ImageRequest.Builder(context).data(preview) .memoryCacheKey("peer-stripped:${preview.size}:${preview.contentHashCode()}") - .diskCachePolicy(CachePolicy.DISABLED) - .crossfade(false) - .size(128) - .transformations(PreviewBlurTransformation()) - .build(), + .diskCachePolicy(CachePolicy.DISABLED).crossfade(false).size(128) + .transformations(PreviewBlurTransformation()).build(), contentDescription = null, modifier = Modifier .fillMaxSize() diff --git a/core/ui/src/main/java/org/monogram/core/ui/components/SettingsTile.kt b/core/ui/src/main/java/org/monogram/core/ui/components/SettingsTile.kt index 97b8da736..ebc24af59 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/components/SettingsTile.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/components/SettingsTile.kt @@ -3,6 +3,7 @@ package org.monogram.core.ui.components import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -33,6 +34,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -52,19 +55,32 @@ fun SettingsCard( selected: Boolean? = null, enabled: Boolean = true, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, + onLongClickLabel: String? = null, content: @Composable ColumnScope.() -> Unit, ) { val shape = when (position) { - ItemPosition.TOP -> RoundedCornerShape( - topStart = SettingsGroupCorner, - topEnd = SettingsGroupCorner, - ) - ItemPosition.MIDDLE -> RoundedCornerShape(0.dp) - ItemPosition.BOTTOM -> RoundedCornerShape( - bottomStart = SettingsGroupCorner, - bottomEnd = SettingsGroupCorner, - ) - ItemPosition.STANDALONE -> RoundedCornerShape(SettingsGroupCorner) + ItemPosition.TOP -> { + RoundedCornerShape( + topStart = SettingsGroupCorner, + topEnd = SettingsGroupCorner, + ) + } + + ItemPosition.MIDDLE -> { + RoundedCornerShape(0.dp) + } + + ItemPosition.BOTTOM -> { + RoundedCornerShape( + bottomStart = SettingsGroupCorner, + bottomEnd = SettingsGroupCorner, + ) + } + + ItemPosition.STANDALONE -> { + RoundedCornerShape(SettingsGroupCorner) + } } val container by animateColorAsState( targetValue = if (selected == true) { @@ -98,7 +114,32 @@ fun SettingsCard( .clip(shape) .alpha(if (enabled) 1f else 0.5f) .then( - if (onClick != null && enabled) Modifier.clickable(onClick = onClick) else Modifier, + when { + onLongClick != null && enabled -> { + Modifier.combinedClickable( + onClick = { onClick?.invoke() }, + onLongClick = onLongClick, + onLongClickLabel = onLongClickLabel, + ) + } + + onClick != null && enabled -> { + Modifier.clickable(onClick = onClick) + } + + else -> { + Modifier + } + }, + ) + .then( + if (selected != null) { + Modifier.semantics { + this.selected = selected + } + } else { + Modifier + }, ), ) { CompositionLocalProvider(LocalContentColor provides contentColor) { @@ -123,18 +164,27 @@ fun SettingsCardRow( onClick: (() -> Unit)? = null, toggle: Boolean? = null, onToggle: ((Boolean) -> Unit)? = null, + leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, ) { // A switch row is toggled by the whole row, not just the switch: tapping the title has to work. val interaction = when { - toggle != null && onToggle != null -> Modifier.toggleable( - value = toggle, - enabled = enabled, - role = Role.Switch, - onValueChange = onToggle, - ) - onClick != null -> Modifier.clickable(enabled = enabled, onClick = onClick) - else -> Modifier + toggle != null && onToggle != null -> { + Modifier.toggleable( + value = toggle, + enabled = enabled, + role = Role.Switch, + onValueChange = onToggle, + ) + } + + onClick != null -> { + Modifier.clickable(enabled = enabled, onClick = onClick) + } + + else -> { + Modifier + } } Row( modifier = modifier @@ -144,18 +194,22 @@ fun SettingsCardRow( .padding(horizontal = RowHorizontalPadding, vertical = RowVerticalPadding), verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = Modifier - .size(RowIconSize) - .background(color = iconColor.copy(alpha = 0.16f), shape = CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = iconTint ?: iconColor, - ) + if (leadingContent != null) { + leadingContent() + } else { + Box( + modifier = Modifier + .size(RowIconSize) + .background(color = iconColor.copy(alpha = 0.16f), shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = iconTint ?: iconColor, + ) + } } Spacer(Modifier.width(RowIconGap)) Column(modifier = Modifier.weight(1f)) { @@ -196,16 +250,21 @@ fun SettingsTile( iconColor: Color, titleColor: Color = MaterialTheme.colorScheme.onSurface, position: ItemPosition = ItemPosition.STANDALONE, - onClick: () -> Unit, + onClick: (() -> Unit)?, enabled: Boolean = true, selected: Boolean? = null, + leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, + onLongClickLabel: String? = null, ) { SettingsCard( position = position, selected = selected, enabled = enabled, onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = onLongClickLabel, ) { SettingsCardRow( icon = icon, @@ -216,6 +275,7 @@ fun SettingsTile( enabled = enabled, selected = selected, trailingContent = trailingContent, + leadingContent = leadingContent, ) } } diff --git a/core/ui/src/main/java/org/monogram/core/ui/media/MediaCaptionSheet.kt b/core/ui/src/main/java/org/monogram/core/ui/media/MediaCaptionSheet.kt index 81562a0be..0ab51d97e 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/media/MediaCaptionSheet.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/media/MediaCaptionSheet.kt @@ -50,15 +50,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.MotionScheme -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -70,10 +70,10 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle @@ -97,14 +97,22 @@ internal fun ViewerCaptionRow( ) { val hasCaption = !caption.isNullOrBlank() if (!hasCaption && !reserved) return - val dragUp = remember { mutableStateOf(0f) } + val dragUp = remember { mutableFloatStateOf(0f) } Row( - modifier = Modifier.fillMaxWidth().heightIn(min = if (reserved) 78.dp else 0.dp) + modifier = Modifier + .fillMaxWidth() + .heightIn(min = if (reserved) 78.dp else 0.dp) .pointerInput(reserved) { detectVerticalDragGestures( onDragStart = { dragUp.value = 0f }, - onVerticalDrag = { change, amount -> change.consume(); dragUp.value += amount }, - onDragEnd = { if (dragUp.value < -24.dp.toPx()) onExpand(); dragUp.value = 0f }, + onVerticalDrag = { change, amount -> + change.consume() + dragUp.value += amount + }, + onDragEnd = { + if (dragUp.value < -24.dp.toPx()) onExpand() + dragUp.value = 0f + }, onDragCancel = { dragUp.value = 0f }, ) } @@ -114,14 +122,18 @@ internal fun ViewerCaptionRow( ) { if (hasCaption) { Text( - text = captionText(caption.orEmpty(), entities, MaterialTheme.colorScheme.primary, actions), + text = captionText( + caption, entities, MaterialTheme.colorScheme.primary, actions + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - } else Spacer(Modifier.weight(1f)) + } else { + Spacer(Modifier.weight(1f)) + } IconButton(onClick = onExpand, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.ExpandLess, stringResource(R.string.media_caption_expand)) } @@ -134,38 +146,61 @@ internal fun captionText( linkColor: Color, actions: MediaViewerActions, ): AnnotatedString = AnnotatedString.Builder(text).apply { - val linkStyles = TextLinkStyles(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) + val linkStyles = TextLinkStyles( + SpanStyle( + color = linkColor, textDecoration = TextDecoration.Underline + ) + ) for (entity in entities) { val start = entity.offset val end = start.toLong() + entity.length - if (start < 0 || entity.length <= 0 || end > text.length || !text.isUtf16Boundary(start) || - !text.isUtf16Boundary(end.toInt())) continue + if (start < 0 || entity.length <= 0 || end > text.length || !text.isUtf16Boundary( + start + ) || !text.isUtf16Boundary(end.toInt()) + ) { + continue + } val finish = end.toInt() when (entity.type) { - CaptionEntity.Type.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, finish) + CaptionEntity.Type.BOLD -> { + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, finish) + } + CaptionEntity.Type.URL -> { - val raw = entity.value?.takeIf { it.isNotBlank() } ?: text.substring(start, finish) + val raw = entity.value?.takeIf { it.isNotBlank() } ?: text.substring( + start, finish + ) if (raw.any { it.isWhitespace() || it.isISOControl() }) continue val target = when { raw.matches(Regex("[A-Za-z][A-Za-z0-9+.-]*:.*")) -> raw raw.substringBefore('/').contains('.') -> "https://$raw" else -> continue } - addLink(LinkAnnotation.Clickable(target, linkStyles) { actions.onCaptionUrl(target) }, start, finish) + addLink(LinkAnnotation.Clickable(target, linkStyles) { + actions.onCaptionUrl( + target + ) + }, start, finish) } + CaptionEntity.Type.MENTION -> { - val target = entity.value?.takeIf { it.matches(Regex("tg://user\\?id=[1-9][0-9]*")) } - ?: text.substring(start, finish).takeIf { it.matches(Regex("@[A-Za-z0-9_]+")) } - ?.let { "https://t.me/${it.removePrefix("@")}" } - ?: continue - addLink(LinkAnnotation.Clickable(target, linkStyles) { actions.onCaptionMention(target) }, start, finish) + val target = + entity.value?.takeIf { it.matches(Regex("tg://user\\?id=[1-9][0-9]*")) } + ?: text.substring(start, finish) + .takeIf { it.matches(Regex("@[A-Za-z0-9_]+")) } + ?.let { "https://t.me/${it.removePrefix("@")}" } ?: continue + addLink( + LinkAnnotation.Clickable( + target, linkStyles + ) { actions.onCaptionMention(target) }, start, finish + ) } } } }.toAnnotatedString() -private fun String.isUtf16Boundary(index: Int): Boolean = index in 0..length && - (index == 0 || index == length || !(this[index - 1].isHighSurrogate() && this[index].isLowSurrogate())) +private fun String.isUtf16Boundary(index: Int): Boolean = + index in 0..length && (index == 0 || index == length || !(this[index - 1].isHighSurrogate() && this[index].isLowSurrogate())) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -186,24 +221,38 @@ internal fun MediaCaptionSheet( BackHandler(enabled = visible, onBack = onCollapse) AnimatedVisibility( visible = visible, - enter = slideInHorizontally(MediaMotion.spatial(motion)) { it } + fadeIn(MediaMotion.effects(motion)), - exit = slideOutHorizontally(MediaMotion.spatial(motion)) { it } + fadeOut(MediaMotion.effects(motion)), + enter = slideInHorizontally(MediaMotion.spatial(motion)) { it } + fadeIn( + MediaMotion.effects( + motion + ) + ), + exit = slideOutHorizontally(MediaMotion.spatial(motion)) { it } + fadeOut( + MediaMotion.effects( + motion + ) + ), modifier = modifier, ) { Surface( color = MaterialTheme.colorScheme.surfaceContainer, shape = RectangleShape, - modifier = Modifier.fillMaxHeight().width(360.dp) + modifier = Modifier + .fillMaxHeight() + .width(360.dp) .windowInsetsPadding(WindowInsets.safeDrawing), ) { - CaptionContent(item, index, albumSize, actions, expanded = true, onCollapse = onCollapse) + CaptionContent( + item, index, albumSize, actions, expanded = true, onCollapse = onCollapse + ) } } } else if (visible) { val density = LocalDensity.current val state = remember { SheetState( - enabledValues = setOf(SheetValue.Hidden, SheetValue.PartiallyExpanded, SheetValue.Expanded), + enabledValues = setOf( + SheetValue.Hidden, SheetValue.PartiallyExpanded, SheetValue.Expanded + ), positionalThreshold = { with(density) { 56.dp.toPx() } }, velocityThreshold = { with(density) { 125.dp.toPx() } }, initialValue = if (detent == CaptionDetent.EXPANDED) SheetValue.Expanded else SheetValue.PartiallyExpanded, @@ -229,7 +278,10 @@ internal fun MediaCaptionSheet( contentWindowInsets = { WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) }, ) { CaptionContent( - item, index, albumSize, actions, + item, + index, + albumSize, + actions, expanded = state.targetValue == SheetValue.Expanded, onCollapse = onCollapse, modifier = Modifier.height((LocalConfiguration.current.screenHeightDp * 0.72f).dp), @@ -251,12 +303,23 @@ private fun CaptionContent( modifier: Modifier = Modifier, ) { val hasCaption = !item.caption.isNullOrBlank() - val shapes = ButtonDefaults.shapes(shape = CircleShape, pressedShape = RoundedCornerShape(ExpressiveDefaults.PressRadius)) - Column(modifier.fillMaxSize().padding(horizontal = 20.dp)) { + val shapes = ButtonDefaults.shapes( + shape = CircleShape, pressedShape = RoundedCornerShape(ExpressiveDefaults.PressRadius) + ) + Column( + modifier + .fillMaxSize() + .padding(horizontal = 20.dp) + ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { item.senderName?.takeIf { it.isNotBlank() }?.let { - Text(it, style = ExpressiveDefaults.nameSemiBold(), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + it, + style = ExpressiveDefaults.nameSemiBold(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { item.dateLabel?.takeIf { it.isNotBlank() }?.let { MetaChip(it) } @@ -265,18 +328,29 @@ private fun CaptionContent( } } IconButton(onClick = onCollapse) { - Icon(Icons.Default.KeyboardArrowDown, stringResource(R.string.media_caption_collapse)) + Icon( + Icons.Default.KeyboardArrowDown, stringResource(R.string.media_caption_collapse) + ) } } - HorizontalDivider(Modifier.padding(top = 12.dp), color = MaterialTheme.colorScheme.outlineVariant) + HorizontalDivider( + Modifier.padding(top = 12.dp), color = MaterialTheme.colorScheme.outlineVariant + ) Column( - Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()).padding(vertical = 16.dp), + Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp), ) { SelectionContainer { Text( text = captionText( - item.caption?.takeIf { it.isNotBlank() } ?: stringResource(R.string.media_caption_empty), - item.captionEntities, MaterialTheme.colorScheme.primary, actions, + item.caption?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.media_caption_empty), + item.captionEntities, + MaterialTheme.colorScheme.primary, + actions, ), style = MaterialTheme.typography.bodyLarge, color = if (hasCaption) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, @@ -286,24 +360,46 @@ private fun CaptionContent( } } HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Row(Modifier.fillMaxWidth().padding(vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { FilledTonalButton( - onClick = { actions.onShowInChat(item) }, shapes = shapes, + onClick = { actions.onShowInChat(item) }, + shapes = shapes, contentPadding = PaddingValues(horizontal = 8.dp), - modifier = Modifier.weight(1f).height(52.dp), + modifier = Modifier + .weight(1f) + .height(52.dp), ) { Icon(Icons.Outlined.ChatBubbleOutline, null, Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.media_action_show_in_chat), style = ExpressiveDefaults.actionSemiBold(), maxLines = 1) + Text( + stringResource(R.string.media_action_show_in_chat), + style = ExpressiveDefaults.actionSemiBold(), + maxLines = 1 + ) } - if (!item.protectedContent) FilledTonalButton( - onClick = { actions.onCopyCaption(item) }, enabled = hasCaption, shapes = shapes, - contentPadding = PaddingValues(horizontal = 8.dp), - modifier = Modifier.weight(1f).height(52.dp), - ) { - Icon(Icons.Default.ContentCopy, null, Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.media_action_copy_caption), style = ExpressiveDefaults.actionSemiBold(), maxLines = 1) + if (!item.protectedContent) { + FilledTonalButton( + onClick = { actions.onCopyCaption(item) }, + enabled = hasCaption, + shapes = shapes, + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = Modifier + .weight(1f) + .height(52.dp), + ) { + Icon(Icons.Default.ContentCopy, null, Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.media_action_copy_caption), + style = ExpressiveDefaults.actionSemiBold(), + maxLines = 1 + ) + } } } } @@ -314,20 +410,33 @@ private fun CaptionContent( private fun viewerSheetMotion(motion: Boolean): MotionScheme { val themeMotion = MaterialTheme.motionScheme return remember(motion, themeMotion) { - if (motion) themeMotion else object : MotionScheme { - override fun defaultSpatialSpec(): FiniteAnimationSpec = snap() - override fun fastSpatialSpec(): FiniteAnimationSpec = snap() - override fun slowSpatialSpec(): FiniteAnimationSpec = snap() - override fun defaultEffectsSpec(): FiniteAnimationSpec = snap() - override fun fastEffectsSpec(): FiniteAnimationSpec = snap() - override fun slowEffectsSpec(): FiniteAnimationSpec = snap() + if (motion) { + themeMotion + } else { + object : MotionScheme { + override fun defaultSpatialSpec(): FiniteAnimationSpec = snap() + + override fun fastSpatialSpec(): FiniteAnimationSpec = snap() + + override fun slowSpatialSpec(): FiniteAnimationSpec = snap() + + override fun defaultEffectsSpec(): FiniteAnimationSpec = snap() + + override fun fastEffectsSpec(): FiniteAnimationSpec = snap() + + override fun slowEffectsSpec(): FiniteAnimationSpec = snap() + } } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -internal fun MediaInfoSheet(item: MediaViewerItem, session: MediaPlaybackSession?, onDismiss: () -> Unit) { +internal fun MediaInfoSheet( + item: MediaViewerItem, + session: MediaPlaybackSession?, + onDismiss: () -> Unit, +) { val context = LocalContext.current val unknown = stringResource(R.string.media_info_unknown) val yes = stringResource(R.string.media_info_yes) @@ -338,22 +447,34 @@ internal fun MediaInfoSheet(item: MediaViewerItem, session: MediaPlaybackSession kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { file.takeIf { it.isFile }?.length()?.takeIf { it > 0 } } - } else null + } else { + null + } } val size = item.fileSize?.takeIf { it > 0 } ?: localSize - val playerSize = if (item.isVideo && session?.current?.id == item.id && - session.player.currentMediaItem?.mediaId == item.id && session.aspectRatio > 0f - ) session.player.videoSize else null + val playerSize = + if (item.isVideo && session?.current?.id == item.id && session.player.currentMediaItem?.mediaId == item.id && session.aspectRatio > 0f) { + session.player.videoSize + } else { + null + } val width = playerSize?.width?.takeIf { it > 0 } ?: item.width val height = playerSize?.height?.takeIf { it > 0 } ?: item.height - val hdr = if (item.isVideo && session?.current?.id == item.id && session.videoHdr != MediaHdr.None) { - session.videoHdr - } else item.hdr + val hdr = + if (item.isVideo && session?.current?.id == item.id && session.videoHdr != MediaHdr.None) { + session.videoHdr + } else { + item.hdr + } val date = item.dateMillis?.takeIf { it > 0 }?.let { - java.text.DateFormat.getDateTimeInstance(java.text.DateFormat.MEDIUM, java.text.DateFormat.SHORT) - .format(java.util.Date(it)) + java.text.DateFormat.getDateTimeInstance( + java.text.DateFormat.MEDIUM, java.text.DateFormat.SHORT + ).format(java.util.Date(it)) } ?: item.dateLabel?.takeIf { it.isNotBlank() } ?: unknown - val state = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val state = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded), + ) MaterialTheme(motionScheme = viewerSheetMotion(mediaViewerMotionEnabled())) { ModalBottomSheet( onDismissRequest = onDismiss, @@ -364,33 +485,77 @@ internal fun MediaInfoSheet(item: MediaViewerItem, session: MediaPlaybackSession contentWindowInsets = { WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) }, ) { Column( - Modifier.fillMaxWidth().verticalScroll(rememberScrollState()) + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) .padding(start = 24.dp, end = 24.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text(stringResource(R.string.media_action_info), style = MaterialTheme.typography.titleLarge) - InfoRow("name", stringResource(R.string.media_info_name), item.fileName?.takeIf { it.isNotBlank() } ?: unknown) - InfoRow("size", stringResource(R.string.media_info_size), size?.let { android.text.format.Formatter.formatFileSize(context, it) } ?: unknown) - InfoRow("dimensions", stringResource(R.string.media_info_dimensions), + Text( + stringResource(R.string.media_action_info), + style = MaterialTheme.typography.titleLarge + ) + InfoRow( + "name", + stringResource(R.string.media_info_name), + item.fileName?.takeIf { it.isNotBlank() } ?: unknown) + InfoRow( + "size", + stringResource(R.string.media_info_size), + size?.let { + android.text.format.Formatter.formatFileSize(context, it) + } ?: unknown, + ) + InfoRow( + "dimensions", + stringResource(R.string.media_info_dimensions), if (width != null && width > 0 && height != null && height > 0) { stringResource(R.string.media_info_dimensions_value, width, height) - } else unknown, + } else { + unknown + }, ) - InfoRow("duration", stringResource(R.string.media_info_duration), - item.durationSeconds?.takeIf { it > 0 }?.let { android.text.format.DateUtils.formatElapsedTime(it.toLong()) } ?: unknown, + InfoRow( + "duration", + stringResource(R.string.media_info_duration), + item.durationSeconds?.takeIf { it > 0 }?.let { + android.text.format.DateUtils.formatElapsedTime( + it.toLong(), + ) + } ?: unknown, ) InfoRow("date", stringResource(R.string.media_info_date), date) - InfoRow("hdr", stringResource(R.string.media_info_hdr), if (hdr != MediaHdr.None) yes else no) - InfoRow("gainmap", stringResource(R.string.media_info_gainmap), if (hdr == MediaHdr.GainMap) yes else no) + InfoRow( + "hdr", + stringResource(R.string.media_info_hdr), + if (hdr != MediaHdr.None) yes else no + ) + InfoRow( + "gainmap", + stringResource(R.string.media_info_gainmap), + if (hdr == MediaHdr.GainMap) yes else no + ) } } } } @Composable -private fun InfoRow(tag: String, label: String, value: String) { - Column(Modifier.fillMaxWidth().testTag("media_info_$tag").semantics(mergeDescendants = true) {}) { - Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) +private fun InfoRow( + tag: String, + label: String, + value: String, +) { + Column( + Modifier + .fillMaxWidth() + .testTag("media_info_$tag") + .semantics(mergeDescendants = true) {}) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) SelectionContainer { Text(value, style = MaterialTheme.typography.bodyLarge) } } } @@ -402,7 +567,11 @@ private fun MetaChip(text: String) { contentColor = MaterialTheme.colorScheme.onPrimaryContainer, shape = CircleShape, ) { - Box(Modifier.height(28.dp).padding(horizontal = 10.dp), contentAlignment = Alignment.Center) { + Box( + Modifier + .height(28.dp) + .padding(horizontal = 10.dp), contentAlignment = Alignment.Center + ) { Text(text, style = MaterialTheme.typography.labelMedium, maxLines = 1) } } diff --git a/core/ui/src/main/java/org/monogram/core/ui/media/MediaPlaybackSession.kt b/core/ui/src/main/java/org/monogram/core/ui/media/MediaPlaybackSession.kt index c73623a07..17eb908ed 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/media/MediaPlaybackSession.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/media/MediaPlaybackSession.kt @@ -19,7 +19,6 @@ import androidx.media3.common.Player import androidx.media3.common.VideoSize import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.MediaSource as PlaybackMediaSource import androidx.media3.session.MediaSession import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -31,6 +30,7 @@ import kotlinx.coroutines.launch import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import androidx.media3.exoplayer.source.MediaSource as PlaybackMediaSource /** * The single playback session shared by every surface: the viewer stage, the mini @@ -43,13 +43,13 @@ import java.util.concurrent.TimeUnit * keys, headset buttons, the media session service and tests. */ @Stable -class MediaPlaybackSession(private val context: Context) { - - val player: ExoPlayer = ExoPlayer.Builder(context) - .setMediaSourceFactory(DefaultMediaSourceFactory(context)) - .setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus = */ true) - .setHandleAudioBecomingNoisy(true) - .build() +class MediaPlaybackSession( + private val context: Context, +) { + val player: ExoPlayer = + ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(context)) + .setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus = */ true) + .setHandleAudioBecomingNoisy(true).build() val mediaSession: MediaSession = MediaSession.Builder(context, player).build() @@ -113,6 +113,7 @@ class MediaPlaybackSession(private val context: Context) { /** MediaSession next/prev may only walk playable items, never land on a photo. */ private val videoIndices: List get() = queue.indices.filter { queue[it].isPlayable && queue[it].source != null } val hasNextVideo: Boolean get() = videoIndices.any { it > index } + /** True while a mixed album is held at the end of a video instead of advancing. */ val pausesAtEndOfMixedVideo: Boolean get() = !isAlbumVideoOnly && !isMessagePlayback val hasPreviousVideo: Boolean get() = videoIndices.any { it < index } @@ -123,12 +124,17 @@ class MediaPlaybackSession(private val context: Context) { private val listener = object : Player.Listener { override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { val format = tracks.groups.firstNotNullOfOrNull { group -> - if (group.type != C.TRACK_TYPE_VIDEO) null else { - (0 until group.length).firstOrNull(group::isTrackSelected)?.let(group::getTrackFormat) + if (group.type != C.TRACK_TYPE_VIDEO) { + null + } else { + (0 until group.length).firstOrNull(group::isTrackSelected) + ?.let(group::getTrackFormat) } } videoHdr = hdrForVideoFormat(format) - android.util.Log.d("MediaViewerHDR", "video ColorInfo=${format?.colorInfo}, hdr=$videoHdr") + android.util.Log.d( + "MediaViewerHDR", "video ColorInfo=${format?.colorInfo}, hdr=$videoHdr" + ) } override fun onIsPlayingChanged(isPlaying: Boolean) { @@ -145,9 +151,7 @@ class MediaPlaybackSession(private val context: Context) { } override fun onVideoSizeChanged(videoSize: VideoSize) { - if (current?.isVideo == true && player.currentMediaItem?.mediaId == current?.id && - videoSize.width > 0 && videoSize.height > 0 - ) { + if (current?.isVideo == true && player.currentMediaItem?.mediaId == current?.id && videoSize.width > 0 && videoSize.height > 0) { aspectRatio = videoSize.width * videoSize.pixelWidthHeightRatio / videoSize.height } } @@ -157,12 +161,13 @@ class MediaPlaybackSession(private val context: Context) { if (isMessagePlayback) messageSpeed = speed else videoSpeed = speed } - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { // The notification, lockscreen and headset keys drive the player's own queue. // Only settle those transitions here: playlist changes are our own resync. - if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_AUTO && - reason != Player.MEDIA_ITEM_TRANSITION_REASON_SEEK - ) { + if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_AUTO && reason != Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { return } val targetId = mediaItem?.mediaId ?: return @@ -227,21 +232,25 @@ class MediaPlaybackSession(private val context: Context) { } fun togglePlayPause() = onMain { - if (failed || player.playbackState == Player.STATE_ENDED || !player.playWhenReady) play() - else pause() + if (failed || player.playbackState == Player.STATE_ENDED || !player.playWhenReady) { + play() + } else { + pause() + } } fun play() = onMain { if (current?.isPlayable != true) return@onMain - if (player.playbackState == Player.STATE_ENDED || - (durationMs > 0L && player.currentPosition >= durationMs) - ) { + if (player.playbackState == Player.STATE_ENDED || (durationMs > 0L && player.currentPosition >= durationMs)) { player.seekTo(0L) positionMs = 0L current?.let { rememberPosition(it.id, 0L) } } - if (failed || player.playbackState == Player.STATE_IDLE) prepareCurrent(true, retry = true) - else player.playWhenReady = true + if (failed || player.playbackState == Player.STATE_IDLE) { + prepareCurrent(true, retry = true) + } else { + player.playWhenReady = true + } } fun pause() = onMain { player.playWhenReady = false } @@ -323,10 +332,16 @@ class MediaPlaybackSession(private val context: Context) { floatNote(explicit = false) } - fun registerNoteVisibility(id: String, visible: Boolean) = onMain { + fun registerNoteVisibility( + id: String, + visible: Boolean, + ) = onMain { if (!isCurrentNote(id)) return@onMain - if (visible && !explicitNoteFloat) returnNoteInline(id) - else if (!visible) floatNote(explicit = false) + if (visible && !explicitNoteFloat) { + returnNoteInline(id) + } else if (!visible) { + floatNote(explicit = false) + } } private fun isCurrentNote(id: String): Boolean = @@ -335,7 +350,10 @@ class MediaPlaybackSession(private val context: Context) { /** Called when the notification's play/pause is used while no UI is on screen. */ fun notificationToggle() = togglePlayPause() - fun rememberPosition(id: String, position: Long) = onMain { + fun rememberPosition( + id: String, + position: Long, + ) = onMain { resumePositions.removeAll { it.first == id } resumePositions.add(id to position) } @@ -344,8 +362,11 @@ class MediaPlaybackSession(private val context: Context) { fun holdPosition() = onMain { val id = current?.id ?: return@onMain - if (player.currentMediaItem?.mediaId == id) sample() - else rememberPosition(id, positionMs) + if (player.currentMediaItem?.mediaId == id) { + sample() + } else { + rememberPosition(id, positionMs) + } } /** Stops the session completely: the mini player disappears and no media stays loaded. */ @@ -393,8 +414,11 @@ class MediaPlaybackSession(private val context: Context) { audioOnly = false floatingNote = false explicitNoteFloat = false - if (current?.kind == MediaViewerKind.VIDEO_NOTE) surface = MediaSurface.CHAT - else if (surface == MediaSurface.CHAT) surface = MediaSurface.STOPPED + if (current?.kind == MediaViewerKind.VIDEO_NOTE) { + surface = MediaSurface.CHAT + } else if (surface == MediaSurface.CHAT) { + surface = MediaSurface.STOPPED + } } private fun validSpeed(value: Float): Float = @@ -404,15 +428,18 @@ class MediaPlaybackSession(private val context: Context) { speed = if (isMessagePlayback) messageSpeed else videoSpeed player.setPlaybackSpeed(speed) val looping = current?.let { item -> - item.kind == MediaViewerKind.VIDEO && - (item.forceLoop || (loopOverrides[item.id] ?: item.loops)) + item.kind == MediaViewerKind.VIDEO && (item.forceLoop || (loopOverrides[item.id] + ?: item.loops)) } == true player.repeatMode = if (looping) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF player.pauseAtEndOfMediaItems = !looping && !isAlbumVideoOnly && !isMessagePlayback player.volume = if (muted) 0f else 1f } - private fun prepareCurrent(autoplay: Boolean, retry: Boolean = false) { + private fun prepareCurrent( + autoplay: Boolean, + retry: Boolean = false, + ) { val item = current if (item == null || !item.isPlayable || item.source == null) { player.pause() @@ -429,13 +456,11 @@ class MediaPlaybackSession(private val context: Context) { val playable = queue.filter { it.isPlayable && it.source != null } val playableIndex = playable.indexOfFirst { it.id == item.id } if (playableIndex < 0) return - val sameSet = player.mediaItemCount == playable.size && - playable.indices.all { - val loaded = player.getMediaItemAt(it) - val requested = mediaItemFor(playable[it]) - loaded.mediaId == requested?.mediaId && - loaded.localConfiguration?.uri == requested?.localConfiguration?.uri - } + val sameSet = player.mediaItemCount == playable.size && playable.indices.all { + val loaded = player.getMediaItemAt(it) + val requested = mediaItemFor(playable[it]) ?: return@all false + loaded.mediaId == requested.mediaId && loaded.localConfiguration?.uri == requested.localConfiguration?.uri && loaded.localConfiguration?.tag == requested.localConfiguration?.tag + } applyPlaybackSettings() if (sameSet && player.currentMediaItem?.mediaId == item.id) { if (retry) { @@ -448,7 +473,12 @@ class MediaPlaybackSession(private val context: Context) { val sources = playable.mapNotNull(::mediaSourceFor) if (sources.isEmpty()) return failed = false - player.setMediaSources(sources, playableIndex, positionFor(item.id)) + val revisionChanged = player.currentMediaItem?.let { + it.mediaId == item.id && it.localConfiguration?.tag != item.contentRevision + } == true + player.setMediaSources( + sources, playableIndex, if (revisionChanged) 0L else positionFor(item.id) + ) player.prepare() player.playWhenReady = autoplay } @@ -462,7 +492,8 @@ class MediaPlaybackSession(private val context: Context) { null -> null } return if (factory != null) { - DefaultMediaSourceFactory(context).setDataSourceFactory(factory).createMediaSource(mediaItem) + DefaultMediaSourceFactory(context).setDataSourceFactory(factory) + .createMediaSource(mediaItem) } else { DefaultMediaSourceFactory(context).createMediaSource(mediaItem) } @@ -474,11 +505,10 @@ class MediaPlaybackSession(private val context: Context) { is MediaSource.Local -> android.net.Uri.fromFile(source.file) is MediaSource.Stream -> source.uri } - val metadata = MediaMetadata.Builder() - .setTitle(item.fileName ?: item.caption) - .setArtist(item.senderName) - .build() - return MediaItem.Builder().setUri(uri).setMediaId(item.id).setMediaMetadata(metadata).build() + val metadata = MediaMetadata.Builder().setTitle(item.fileName ?: item.caption) + .setArtist(item.senderName).build() + return MediaItem.Builder().setUri(uri).setMediaId(item.id).setTag(item.contentRevision) + .setMediaMetadata(metadata).build() } private fun onMain(block: () -> Unit) { @@ -495,8 +525,7 @@ class MediaPlaybackSession(private val context: Context) { latch.countDown() } latch.await(2, TimeUnit.SECONDS) - @Suppress("UNCHECKED_CAST") - return result as T + @Suppress("UNCHECKED_CAST") return result as T } } diff --git a/core/ui/src/main/java/org/monogram/core/ui/media/MediaViewerModel.kt b/core/ui/src/main/java/org/monogram/core/ui/media/MediaViewerModel.kt index aa13e95ae..9a48a2706 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/media/MediaViewerModel.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/media/MediaViewerModel.kt @@ -14,28 +14,46 @@ import java.io.File enum class MediaViewerKind { PHOTO, VIDEO, AUDIO, VOICE, VIDEO_NOTE, UNKNOWN } @Immutable -data class CaptionEntity(val offset: Int, val length: Int, val type: Type, val value: String? = null) { +data class CaptionEntity( + val offset: Int, + val length: Int, + val type: Type, + val value: String? = null, +) { enum class Type { URL, MENTION, BOLD } } @Immutable sealed interface MediaHdr { data object None : MediaHdr + data object GainMap : MediaHdr - data class Video(val hdrStaticInfoLabel: String) : MediaHdr + + data class Video( + val hdrStaticInfoLabel: String, + ) : MediaHdr } @Immutable -data class AlbumSelectionState(val active: Boolean = false, val ids: Set = emptySet()) { +data class AlbumSelectionState( + val active: Boolean = false, + val ids: Set = emptySet(), +) { fun contains(id: String): Boolean = if (active) id in ids else false } /** Where the bytes come from. [Stream] is used while a video is still downloading. */ sealed interface MediaSource { @Immutable - data class Local(val file: File, val factory: DataSource.Factory? = null) : MediaSource - - data class Stream(val uri: Uri, val factory: DataSource.Factory? = null) : MediaSource + data class Local( + val file: File, + val factory: DataSource.Factory? = null, + ) : MediaSource + + data class Stream( + val uri: Uri, + val factory: DataSource.Factory? = null, + ) : MediaSource } /** @@ -47,6 +65,7 @@ data class MediaViewerItem( val id: String, val kind: MediaViewerKind, val source: MediaSource? = null, + val contentRevision: String? = null, /** Thumbnail or blurhash stand-in shown before the full media arrives. */ val preview: File? = null, val caption: String? = null, @@ -86,8 +105,8 @@ data class MediaViewerItem( /** Short videos loop while they are the current page; longer ones never do. */ val loops: Boolean - get() = kind == MediaViewerKind.VIDEO && !protectedContent && - (forceLoop || (durationSeconds ?: 0) in 1..LOOP_MAX_SECONDS) + get() = kind == MediaViewerKind.VIDEO && !protectedContent && (forceLoop || (durationSeconds + ?: 0) in 1..LOOP_MAX_SECONDS) companion object { const val LOOP_MAX_SECONDS = 30 @@ -149,27 +168,42 @@ class MediaAlbumState( val count: Int get() = items.size val hasAlbum: Boolean get() = items.size > 1 - fun update(items: List, index: Int? = null) { + fun update( + items: List, + index: Int? = null, + ) { val currentId = current?.id - val retainedIndex = items.indexOfFirst { it.id == currentId }.takeIf { it >= 0 } ?: this.index + val retainedIndex = + items.indexOfFirst { it.id == currentId }.takeIf { it >= 0 } ?: this.index this.items = items.map { item -> val hdr = detectedHdr[item.id] ?: item.hdr val dimensions = detectedDimensions[item.id] - item.copy(hdr = hdr, width = dimensions?.first ?: item.width, height = dimensions?.second ?: item.height) + item.copy( + hdr = hdr, + width = dimensions?.first ?: item.width, + height = dimensions?.second ?: item.height + ) } this.index = (index ?: retainedIndex).coerceIn(0, (items.size - 1).coerceAtLeast(0)) val retained = selection.ids.intersect(items.mapTo(mutableSetOf()) { it.id }) selection = AlbumSelectionState(retained.isNotEmpty(), retained) } - fun setHdr(id: String, hdr: MediaHdr) { + fun setHdr( + id: String, + hdr: MediaHdr, + ) { detectedHdr[id] = hdr if (items.any { it.id == id && it.hdr != hdr }) { items = items.map { if (it.id == id) it.copy(hdr = hdr) else it } } } - fun setDimensions(id: String, width: Int, height: Int) { + fun setDimensions( + id: String, + width: Int, + height: Int, + ) { if (width <= 0 || height <= 0) return detectedDimensions[id] = width to height if (items.any { it.id == id && (it.width != width || it.height != height) }) { @@ -183,7 +217,9 @@ class MediaAlbumState( selection = AlbumSelectionState(active = next.isNotEmpty(), ids = next) } - fun clearSelection() { selection = AlbumSelectionState() } + fun clearSelection() { + selection = AlbumSelectionState() + } fun selectedItems(): List = if (selection.active) { items.filter { it.id in selection.ids } diff --git a/core/ui/src/main/java/org/monogram/core/ui/media/VoiceWaveformExtraction.kt b/core/ui/src/main/java/org/monogram/core/ui/media/VoiceWaveformExtraction.kt index ebd9a9032..c8815c007 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/media/VoiceWaveformExtraction.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/media/VoiceWaveformExtraction.kt @@ -5,22 +5,21 @@ import android.media.MediaCodec import android.media.MediaExtractor import android.media.MediaFormat import android.os.SystemClock -import java.io.File -import java.nio.ByteOrder -import java.util.Collections import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.monogram.core.ui.FileLeases -import kotlin.math.abs +import java.io.File +import java.nio.ByteOrder +import java.util.Collections private const val WAVEFORM_BINS = 64 private const val MAX_FILE_BYTES = 64L * 1024 * 1024 @@ -29,14 +28,19 @@ private const val MAX_DURATION_US = 30L * 60 * 1_000_000 private const val MAX_DECODE_MS = 20_000L private const val CODEC_TIMEOUT_US = 10_000L -private data class WaveformFile(val path: String, val modified: Long, val length: Long) +private data class WaveformFile( + val path: String, + val modified: Long, + val length: Long, +) private val waveformMutex = Mutex() private val decodeMutex = Mutex() private val waveformScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val waveformInFlight = mutableMapOf>>() private val waveformCache = object : LinkedHashMap>(128, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry>): Boolean = size > 128 + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>): Boolean = + size > 128 } suspend fun voiceWaveform(file: File): List = withContext(Dispatchers.IO) { @@ -45,27 +49,29 @@ suspend fun voiceWaveform(file: File): List = withContext(Dispatchers.IO) if (!file.isFile || identity.length !in 1..MAX_FILE_BYTES) return@withContext emptyList() val deferred = waveformMutex.withLock { waveformCache[identity]?.let { return@withContext it } - waveformInFlight[identity] ?: waveformScope.async(start = kotlinx.coroutines.CoroutineStart.LAZY) { - try { - val amplitudes = decodeMutex.withLock { - FileLeases.hold(file) { decodeVoiceWaveform(file) } - } - waveformMutex.withLock { - if (file.lastModified() == identity.modified && file.length() == identity.length) { - waveformCache.keys.removeAll { it.path == identity.path && it != identity } - waveformCache[identity] = amplitudes - amplitudes - } else emptyList() + waveformInFlight[identity] + ?: waveformScope.async(start = kotlinx.coroutines.CoroutineStart.LAZY) { + try { + val amplitudes = decodeMutex.withLock { + FileLeases.hold(file) { decodeVoiceWaveform(file) } + } + waveformMutex.withLock { + if (amplitudes.isNotEmpty() && file.lastModified() == identity.modified && file.length() == identity.length) { + waveformCache.keys.removeAll { it.path == identity.path && it != identity } + waveformCache[identity] = amplitudes + amplitudes + } else { + emptyList() + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + emptyList() + } finally { + waveformMutex.withLock { waveformInFlight.remove(identity) } } - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - waveformMutex.withLock { waveformCache[identity] = emptyList() } - emptyList() - } finally { - waveformMutex.withLock { waveformInFlight.remove(identity) } - } - }.also { waveformInFlight[identity] = it } + }.also { waveformInFlight[identity] = it } } deferred.await() } @@ -85,7 +91,8 @@ private suspend fun decodeVoiceWaveform(file: File): List { try { extractor.setDataSource(file.absolutePath) val track = (0 until extractor.trackCount).firstOrNull { - extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true + extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME) + ?.startsWith("audio/") == true } ?: return emptyList() val format = extractor.getTrackFormat(track) val mime = format.getString(MediaFormat.KEY_MIME) ?: return emptyList() @@ -124,7 +131,9 @@ private suspend fun decodeVoiceWaveform(file: File): List { input.clear() val bytes = extractor.readSampleData(input, 0) if (bytes < 0) { - codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + codec.queueInputBuffer( + inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM + ) inputEnded = true } else { codec.queueInputBuffer(inputIndex, 0, bytes, extractor.sampleTime, 0) @@ -143,44 +152,51 @@ private suspend fun decodeVoiceWaveform(file: File): List { AudioFormat.ENCODING_PCM_16BIT } } - else -> if (outputIndex >= 0) { - try { - if (info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) { - val bytesPerSample = when (encoding) { - AudioFormat.ENCODING_PCM_16BIT -> 2 - AudioFormat.ENCODING_PCM_FLOAT -> 4 - else -> return emptyList() - } - if (sampleRate !in 1..384_000 || channels !in 1..8) return emptyList() - decodedBytes += info.size - if (decodedBytes > MAX_PCM_BYTES) return emptyList() - val frameBytes = channels * bytesPerSample - if (info.size % frameBytes != 0) return emptyList() - val output = codec.getOutputBuffer(outputIndex) ?: return emptyList() - output.limit(info.offset + info.size) - output.position(info.offset) - output.order(ByteOrder.nativeOrder()) - val pcm = ShortArray(info.size / frameBytes * channels) - for (index in pcm.indices) { - if (index % 4096 == 0) context.ensureActive() - pcm[index] = if (bytesPerSample == 2) { - output.short - } else { - (output.float.coerceIn(-1f, 1f) * 32767f).toInt().toShort() + + else -> { + if (outputIndex >= 0) { + try { + if (info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) { + val bytesPerSample = when (encoding) { + AudioFormat.ENCODING_PCM_16BIT -> 2 + AudioFormat.ENCODING_PCM_FLOAT -> 4 + else -> return emptyList() + } + if (sampleRate !in 1..384_000 || channels !in 1..8) return emptyList() + decodedBytes += info.size + if (decodedBytes > MAX_PCM_BYTES) return emptyList() + val frameBytes = channels * bytesPerSample + if (info.size % frameBytes != 0) return emptyList() + val output = + codec.getOutputBuffer(outputIndex) ?: return emptyList() + output.limit(info.offset + info.size) + output.position(info.offset) + output.order(ByteOrder.nativeOrder()) + val pcm = ShortArray(info.size / frameBytes * channels) + for (index in pcm.indices) { + if (index % 4096 == 0) context.ensureActive() + pcm[index] = if (bytesPerSample == 2) { + output.short + } else { + (output.float.coerceIn(-1f, 1f) * 32767f).toInt().toShort() + } } + if (nativeHandle == 0L) return emptyList() + NativeWaveform.add( + nativeHandle, pcm, sampleRate, channels, info.presentationTimeUs + ) + hasSamples = true } - if (nativeHandle == 0L) return emptyList() - NativeWaveform.add(nativeHandle, pcm, sampleRate, channels, info.presentationTimeUs) - hasSamples = true + outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 + } finally { + codec.releaseOutputBuffer(outputIndex, false) } - outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 - } finally { - codec.releaseOutputBuffer(outputIndex, false) } } } } - val waveform = if (hasSamples && nativeHandle != 0L) NativeWaveform.finish(nativeHandle) else emptyList() + val waveform = + if (hasSamples && nativeHandle != 0L) NativeWaveform.finish(nativeHandle) else emptyList() return Collections.unmodifiableList(waveform) } finally { if (nativeHandle != 0L) NativeWaveform.destroy(nativeHandle) diff --git a/core/ui/src/main/java/org/monogram/core/ui/menu/AppMenu.kt b/core/ui/src/main/java/org/monogram/core/ui/menu/AppMenu.kt index 7826f0368..a911a5577 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/menu/AppMenu.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/menu/AppMenu.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -93,13 +94,14 @@ internal fun appMenuStateLayerAlpha( focused: Boolean, hovered: Boolean, enabled: Boolean, -): Float = when { - !enabled -> 0f - pressed -> 0.12f - focused -> 0.10f - hovered -> 0.08f - else -> 0f -} +): Float = + when { + !enabled -> 0f + pressed -> 0.12f + focused -> 0.10f + hovered -> 0.08f + else -> 0f + } @Composable fun AppMenuSurface( @@ -109,27 +111,30 @@ fun AppMenuSurface( content: @Composable ColumnScope.() -> Unit, ) { Surface( - modifier = modifier.then( - if (width != null) { - Modifier.width(width) - } else { - Modifier.widthIn(AppMenuDefaults.MinWidth, AppMenuDefaults.MaxWidth) - }, - ), + modifier = + modifier.then( + if (width != null) { + Modifier.width(width) + } else { + Modifier.widthIn(AppMenuDefaults.MinWidth, AppMenuDefaults.MaxWidth) + }, + ), shape = AppMenuDefaults.ContainerShape, color = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 3.dp, shadowElevation = 3.dp, ) { Column( - modifier = Modifier - .padding(AppMenuDefaults.ContainerPadding) - .then(if (scrollState != null) Modifier.verticalScroll(scrollState) else Modifier), - verticalArrangement = if (scrollState != null) { - Arrangement.Top - } else { - Arrangement.spacedBy(AppMenuDefaults.GroupGap) - }, + modifier = + Modifier + .padding(AppMenuDefaults.ContainerPadding) + .then(if (scrollState != null) Modifier.verticalScroll(scrollState) else Modifier), + verticalArrangement = + if (scrollState != null) { + Arrangement.Top + } else { + Arrangement.spacedBy(AppMenuDefaults.GroupGap) + }, content = content, ) } @@ -169,16 +174,22 @@ fun AppMenuAvatarStack( if (shown.isEmpty()) return val overlap = size * 0.3f val stackWidth = size + overlap * (shown.size - 1) - Box(modifier = modifier.width(stackWidth).heightIn(min = size)) { + Box( + modifier = + modifier + .width(stackWidth) + .heightIn(min = size), + ) { shown.forEachIndexed { index, avatar -> PeerAvatar( title = avatar.label, imageFile = avatar.imageFile, previewBytes = avatar.previewBytes, size = size, - modifier = Modifier - .offset(x = overlap * index) - .border(1.5.dp, MaterialTheme.colorScheme.surfaceContainer, CircleShape), + modifier = + Modifier + .offset(x = overlap * index) + .border(1.5.dp, MaterialTheme.colorScheme.surfaceContainer, CircleShape), ) } } @@ -198,6 +209,7 @@ fun AppMenuItem( enabled: Boolean = true, destructive: Boolean = false, contentDescription: String? = null, + selected: Boolean? = null, ) { val interaction = remember { MutableInteractionSource() } val pressed by interaction.collectIsPressedAsState() @@ -205,41 +217,57 @@ fun AppMenuItem( val hovered by interaction.collectIsHoveredAsState() val overlayAlpha = appMenuStateLayerAlpha(pressed, focused, hovered, enabled) val disabledAlpha = if (enabled) 1f else 0.38f - val labelColor = when { - destructive -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurface - }.copy(alpha = disabledAlpha) - val iconColor = when { - destructive -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurfaceVariant - }.copy(alpha = disabledAlpha) + val labelColor = + when { + destructive -> MaterialTheme.colorScheme.error + selected == true -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurface + }.copy(alpha = disabledAlpha) + val iconColor = + when { + destructive -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + }.copy(alpha = disabledAlpha) Row( - modifier = modifier - .fillMaxWidth() - .heightIn(min = AppMenuDefaults.ItemMinHeight) - .clip(AppMenuDefaults.ItemShape) - .background( - if (overlayAlpha > 0f) { - MaterialTheme.colorScheme.onSurface.copy(alpha = overlayAlpha) - } else { - Color.Transparent - }, - ) - .clickable( - interactionSource = interaction, - indication = null, - enabled = enabled, - role = Role.Button, - onClick = onClick, - ) - .padding(horizontal = AppMenuDefaults.ItemHorizontalPadding) - .then( - if (contentDescription != null) { - Modifier.semantics { this.contentDescription = contentDescription } - } else { - Modifier - }, - ), + modifier = + modifier + .fillMaxWidth() + .heightIn(min = AppMenuDefaults.ItemMinHeight) + .clip(AppMenuDefaults.ItemShape) + .then( + if (selected != null) { + Modifier.semantics { + this.selected = selected + } + } else { + Modifier + }, + ) + .background( + if (selected == true) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, + ) + .background( + if (overlayAlpha > 0f) { + MaterialTheme.colorScheme.onSurface.copy(alpha = overlayAlpha) + } else { + Color.Transparent + }, + ) + .clickable( + interactionSource = interaction, + indication = null, + enabled = enabled, + role = Role.Button, + onClick = onClick, + ) + .padding(horizontal = AppMenuDefaults.ItemHorizontalPadding) + .then( + if (contentDescription != null) { + Modifier.semantics { this.contentDescription = contentDescription } + } else { + Modifier + }, + ), verticalAlignment = Alignment.CenterVertically, ) { when { @@ -302,41 +330,50 @@ fun AppMenuItem( } @Composable -fun AppMenuScrim(modifier: Modifier = Modifier, onClick: () -> Unit) { +fun AppMenuScrim( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { Box( - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = AppMenuDefaults.ScrimAlpha)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onClick, - ), + modifier = + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = AppMenuDefaults.ScrimAlpha)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), ) } @Composable -fun AppMenuScrimPopup(visible: Boolean, onDismiss: () -> Unit) { +fun AppMenuScrimPopup( + visible: Boolean, + onDismiss: () -> Unit, +) { val transition = remember { MutableTransitionState(false) } transition.targetState = visible if (transition.isIdle && !transition.currentState && !transition.targetState) return androidx.compose.ui.window.Popup( - popupPositionProvider = remember { - object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: androidx.compose.ui.unit.IntRect, - windowSize: androidx.compose.ui.unit.IntSize, - layoutDirection: LayoutDirection, - popupContentSize: androidx.compose.ui.unit.IntSize, - ): IntOffset = IntOffset.Zero - } - }, - properties = androidx.compose.ui.window.PopupProperties( - focusable = false, - dismissOnBackPress = false, - dismissOnClickOutside = false, - clippingEnabled = false, - ), + popupPositionProvider = + remember { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: androidx.compose.ui.unit.IntRect, + windowSize: androidx.compose.ui.unit.IntSize, + layoutDirection: LayoutDirection, + popupContentSize: androidx.compose.ui.unit.IntSize, + ): IntOffset = IntOffset.Zero + } + }, + properties = + androidx.compose.ui.window.PopupProperties( + focusable = false, + dismissOnBackPress = false, + dismissOnClickOutside = false, + clippingEnabled = false, + ), onDismissRequest = onDismiss, ) { AnimatedVisibility( @@ -367,27 +404,35 @@ fun AppMenuPopup( val placement = remember { AppMenuPlacementState() } if (scrim) AppMenuScrimPopup(visible = expanded, onDismiss = onDismiss) androidx.compose.ui.window.Popup( - popupPositionProvider = rememberAppMenuPositionProvider( - touch = touch, - alignToAnchorEnd = alignToAnchorEnd, - topInset = topInset, - bottomInset = bottomInset, - placementState = placement, - ), + popupPositionProvider = + rememberAppMenuPositionProvider( + touch = touch, + alignToAnchorEnd = alignToAnchorEnd, + topInset = topInset, + bottomInset = bottomInset, + placementState = placement, + ), onDismissRequest = onDismiss, - properties = androidx.compose.ui.window.PopupProperties( - focusable = true, - clippingEnabled = false, - ), + properties = + androidx.compose.ui.window.PopupProperties( + focusable = true, + clippingEnabled = false, + ), ) { - val origin = TransformOrigin( - pivotFractionX = if (alignToAnchorEnd) 1f else 0f, - pivotFractionY = if (placement.growth == AppMenuGrowth.Above) 1f else 0f, - ) + val origin = + TransformOrigin( + pivotFractionX = if (alignToAnchorEnd) 1f else 0f, + pivotFractionY = if (placement.growth == AppMenuGrowth.Above) 1f else 0f, + ) AnimatedVisibility( visibleState = transition, - enter = fadeIn(AppMenuMotion.OpenSpec) + - scaleIn(AppMenuMotion.OpenSpec, initialScale = AppMenuMotion.InitialScale, transformOrigin = origin), + enter = + fadeIn(AppMenuMotion.OpenSpec) + + scaleIn( + AppMenuMotion.OpenSpec, + initialScale = AppMenuMotion.InitialScale, + transformOrigin = origin, + ), exit = fadeOut(AppMenuMotion.CloseSpec), modifier = modifier, ) { diff --git a/core/ui/src/main/java/org/monogram/core/ui/perf/RecompositionProbe.kt b/core/ui/src/main/java/org/monogram/core/ui/perf/RecompositionProbe.kt index 8c3a84b0b..24c8bae20 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/perf/RecompositionProbe.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/perf/RecompositionProbe.kt @@ -1,3 +1,5 @@ +@file:Suppress("NOTHING_TO_INLINE") + package org.monogram.core.ui.perf import androidx.compose.runtime.Composable @@ -9,41 +11,62 @@ import org.monogram.core.common.PerfLog inline fun RecompositionProbe(name: String) { if (PerfLog.isEnabled() || DebugLog.enabled) { SideEffect { - if (PerfLog.isEnabled()) PerfLog.event("recomp", name) - else DebugLog.ingestPerf("recomp", name, null) + if (PerfLog.isEnabled()) { + PerfLog.event("recomp", name) + } else { + DebugLog.ingestPerf("recomp", name, null) + } } } } @Composable -inline fun RecompositionProbe(name: String, detail: Int?) { +inline fun RecompositionProbe( + name: String, + detail: Int?, +) { if (PerfLog.isEnabled() || DebugLog.enabled) { SideEffect { val line = if (detail == null) name else "$name $detail" - if (PerfLog.isEnabled()) PerfLog.event("recomp", line) - else DebugLog.ingestPerf("recomp", line, null) + if (PerfLog.isEnabled()) { + PerfLog.event("recomp", line) + } else { + DebugLog.ingestPerf("recomp", line, null) + } } } } @Composable -inline fun RecompositionProbe(name: String, detail: Int) { +inline fun RecompositionProbe( + name: String, + detail: Int, +) { if (PerfLog.isEnabled() || DebugLog.enabled) { SideEffect { val line = "$name $detail" - if (PerfLog.isEnabled()) PerfLog.event("recomp", line) - else DebugLog.ingestPerf("recomp", line, null) + if (PerfLog.isEnabled()) { + PerfLog.event("recomp", line) + } else { + DebugLog.ingestPerf("recomp", line, null) + } } } } @Composable -inline fun RecompositionProbe(name: String, detail: Long) { +inline fun RecompositionProbe( + name: String, + detail: Long, +) { if (PerfLog.isEnabled() || DebugLog.enabled) { SideEffect { val line = "$name $detail" - if (PerfLog.isEnabled()) PerfLog.event("recomp", line) - else DebugLog.ingestPerf("recomp", line, null) + if (PerfLog.isEnabled()) { + PerfLog.event("recomp", line) + } else { + DebugLog.ingestPerf("recomp", line, null) + } } } } @@ -54,8 +77,11 @@ inline fun RecompositionProbe(name: String, detail: Long) { * emission logs fewer, cheaper spans after the fix. `PerfLog.dump` is deliberately not used * here; probing the native snapshot on every recomputation would drown the log. */ -inline fun perfSpan(op: String, block: () -> T): T { +inline fun perfSpan( + op: String, + block: () -> T, +): T { if (!PerfLog.isEnabled()) return block() val started = PerfLog.nowMs() return block().also { PerfLog.mark(op, PerfLog.nowMs() - started) } -} \ No newline at end of file +} diff --git a/feature/chats/src/androidTest/java/org/monogram/feature/chats/CatchupLoadingDeviceTest.kt b/feature/chats/src/androidTest/java/org/monogram/feature/chats/CatchupLoadingDeviceTest.kt index b730ad903..d23088d06 100644 --- a/feature/chats/src/androidTest/java/org/monogram/feature/chats/CatchupLoadingDeviceTest.kt +++ b/feature/chats/src/androidTest/java/org/monogram/feature/chats/CatchupLoadingDeviceTest.kt @@ -1,5 +1,7 @@ package org.monogram.feature.chats +import android.os.SystemClock +import android.util.Log import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule @@ -40,6 +42,7 @@ class CatchupLoadingDeviceTest { @Test fun cachedStartupAndReconnectKeepUpdatingUntilRecoveryCompletes() { + val startedAt = SystemClock.elapsedRealtime() val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) val lifecycle = LifecycleRegistry() val events = MutableSharedFlow(replay = 1) @@ -70,7 +73,11 @@ class CatchupLoadingDeviceTest { } "loadMoreChats", "loadMoreFolderChats", "getFolders" -> Outcome.Ok(emptyList()) - "getNotifySettings" -> Outcome.Ok(NotifySettings()) + "getNotifySettings" -> { + val continuation = args!!.last() as Continuation + scope.launch { delay(150); continuation.resume(Outcome.Ok(NotifySettings())) } + COROUTINE_SUSPENDED + } "close" -> Unit else -> Outcome.Err("unsupported fixture method") } @@ -86,16 +93,26 @@ class CatchupLoadingDeviceTest { try { compose.onNodeWithText("Synthetic startup chat").assertIsDisplayed() compose.waitUntil(15000) { !component.state.value.loading && !component.state.value.fromCache } + compose.onNodeWithText("Synthetic startup chat").assertIsDisplayed() + Log.i( + "LoadingUiTiming", + "operation=chats_initial_render ms=${SystemClock.elapsedRealtime() - startedAt}" + ) compose.onNodeWithText("Updating").assertIsDisplayed() compose.runOnUiThread { events.tryEmit(MtprotoUpdate.SyncState(false)) } compose.waitUntil(10000) { !component.state.value.syncing } compose.onNodeWithText("Updating").assertDoesNotExist() + val refreshAt = SystemClock.elapsedRealtime() compose.runOnUiThread { events.tryEmit(MtprotoUpdate.SyncState(true)); component.onRefresh() } compose.waitUntil(10000) { component.state.value.syncing && !component.state.value.loading } compose.onNodeWithText("Updating").assertIsDisplayed() compose.runOnUiThread { events.tryEmit(MtprotoUpdate.SyncState(false)) } compose.waitUntil(10000) { !component.state.value.syncing } compose.onNodeWithText("Synthetic startup chat").assertIsDisplayed() + Log.i( + "LoadingUiTiming", + "operation=chats_refresh_render ms=${SystemClock.elapsedRealtime() - refreshAt}" + ) } finally { compose.runOnUiThread { lifecycle.destroy(); scope.cancel() } } diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/AccountMenuItem.kt b/feature/chats/src/main/java/org/monogram/feature/chats/AccountMenuItem.kt new file mode 100644 index 000000000..2062a4e9d --- /dev/null +++ b/feature/chats/src/main/java/org/monogram/feature/chats/AccountMenuItem.kt @@ -0,0 +1,13 @@ +package org.monogram.feature.chats + +import org.monogram.network.http.MediaRepository + +data class AccountMenuItem( + val userId: Long, + val title: String, + val needsSignIn: Boolean = false, + val active: Boolean = false, + val unavailable: Boolean = false, + val avatarCacheKey: String? = null, + val mediaRepository: MediaRepository? = null, +) diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsComponent.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsComponent.kt index ad9eb3388..48578c93e 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsComponent.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsComponent.kt @@ -30,6 +30,9 @@ class ChatsComponent( private val onOpenSettings: () -> Unit = {}, private val onOpenProxy: () -> Unit = {}, private val onOpenFolders: () -> Unit = {}, + val accountMenuItems: List = emptyList(), + private val onSwitchAccount: (Long) -> Unit = {}, + private val onAddAccount: () -> Unit = {}, ) : ComponentContext by componentContext { private val store = instanceKeeper.getStore { @@ -67,5 +70,7 @@ class ChatsComponent( fun onOpenSettings() = onOpenSettings.invoke() fun onOpenProxy() = onOpenProxy.invoke() fun onOpenFolders() = onOpenFolders.invoke() + fun switchAccount(userId: Long) = onSwitchAccount(userId) + fun addAccount() = onAddAccount() fun proxyConnectionStatus(): ProxyConnectionStatus = client.proxyConnectionStatus() } diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsExecutor.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsExecutor.kt index cd9b83423..1afb20fb0 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsExecutor.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsExecutor.kt @@ -8,7 +8,9 @@ import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -64,10 +66,10 @@ internal class ChatsExecutor( private val listPublicationMutex = Mutex() private var lastNetworkPage: List = emptyList() private var refreshSyncing = false - private var protocolSyncing = false + private val protocolSyncing = MutableStateFlow(false) private fun publishSyncing() { - val syncing = refreshSyncing || protocolSyncing + val syncing = refreshSyncing || protocolSyncing.value if (state().syncing != syncing) dispatch(Msg.Syncing(syncing)) } @@ -75,6 +77,7 @@ internal class ChatsExecutor( refreshSyncing = value publishSyncing() } + private var mainNetworkHasMore = true private val failedOffsetPeers = mutableSetOf() @@ -106,6 +109,7 @@ internal class ChatsExecutor( private var roomMainCount = 0 private var titleRecoveryJob: Job? = null private var catalogGeneration = 0 + private var catalogJob: Job? = null private val attemptedTitleRecovery = mutableSetOf() private var searchJob: Job? = null private var searchMoreJob: Job? = null @@ -114,12 +118,10 @@ internal class ChatsExecutor( private var searchNextPeerId = 0L private var searchNextOffsetId = 0 private var lastRunSearchQuery = "" + override fun executeAction(action: Unit) { - warmup?.observeReadStates() - ?.map { rows -> rows.associateBy { it.id } } - ?.distinctUntilChanged() - ?.flowOn(Dispatchers.Default) - ?.onEach { rows -> + warmup?.observeReadStates()?.map { rows -> rows.associateBy { it.id } } + ?.distinctUntilChanged()?.flowOn(Dispatchers.Default)?.onEach { rows -> if (state().chats.isEmpty()) { pendingReadStates = rows return@onEach @@ -129,8 +131,7 @@ internal class ChatsExecutor( listPublicationMutex.withLock { dispatch(Msg.ReadStates(rows)) } - } - ?.launchIn(scope) + }?.launchIn(scope) loadSelf() // A previous run cached the account defaults: the list must keep the right bell before // the settings RPC (or its failure) decides anything. @@ -140,11 +141,10 @@ internal class ChatsExecutor( } scope.launch { loadNotifyDefaults() } refresh(force = false) - client.updates() - .onEach { update -> + client.updates().onEach { update -> when (update) { is MtprotoUpdate.SyncState -> { - protocolSyncing = update.isSyncing + protocolSyncing.value = update.isSyncing publishSyncing() } @@ -176,16 +176,9 @@ internal class ChatsExecutor( ) val rebuiltById = rebuilt.associateBy { it.id } changed = mapped.filter { row -> - val previous = before[row.id.value] ?: return@filter true - previous.muted != row.muted || - previous.title != row.title || - previous.muteOverride != row.muteOverride || - previous.unreadMark != row.unreadMark || - previous.left != row.left || - previous.archived != row.archived || - previous.pinned != row.pinned || - (row.pinnedOrder != Int.MAX_VALUE && previous.pinnedOrder != row.pinnedOrder) - }.map { row -> rebuiltById[row.id] ?: row } + val previous = before[row.id.value] ?: return@filter true + previous.photoCacheKey != rebuiltById[row.id]?.photoCacheKey || previous.muted != row.muted || previous.title != row.title || previous.muteOverride != row.muteOverride || previous.unreadMark != row.unreadMark || previous.left != row.left || previous.archived != row.archived || previous.pinned != row.pinned || (row.pinnedOrder != Int.MAX_VALUE && previous.pinnedOrder != row.pinnedOrder) + }.map { row -> rebuiltById[row.id] ?: row } } if (changed.isNotEmpty()) scope.launch { warmup?.upsertChats(changed) } } @@ -193,12 +186,16 @@ internal class ChatsExecutor( // only for differenceTooLong (Ignored below). } - is MtprotoUpdate.Ignored -> if (update.kind == "difference_too_long") { - refresh(force = false) + is MtprotoUpdate.Ignored -> { + if (update.kind == "difference_too_long") { + refresh(force = false) + } } - is MtprotoUpdate.PeerTyping -> scope.launch { - applyPeerTyping(update) + is MtprotoUpdate.PeerTyping -> { + scope.launch { + applyPeerTyping(update) + } } is MtprotoUpdate.PeerStatus -> { @@ -268,14 +265,18 @@ internal class ChatsExecutor( is MtprotoUpdate.MessagesDeleted -> { scope.launch { - warmup?.deleteMessages(update.chatId, update.messageIds) + if (!update.persisted) { + warmup?.deleteMessages( + update.chatId, + update.messageIds, + ) + } val ids = update.messageIds.toSet() listPublicationMutex.withLock { // Re-read after acquiring the publication boundary: refresh or // an incoming message may already have advanced this dialog. val affected = state().chats.filter { chat -> - (update.chatId == null || update.chatId == chat.id) && - chat.lastMessageId in ids + (update.chatId == null || update.chatId == chat.id) && chat.lastMessageId in ids } for (chat in affected) { val latest = warmup?.messages(chat.id, 1)?.firstOrNull() @@ -287,9 +288,9 @@ internal class ChatsExecutor( is MtprotoUpdate.ReadInbox -> { val current = state().chats.firstOrNull { it.id == update.chatId } - if (current == null || - current.readInboxMaxId != update.maxId || - current.unreadCount != update.stillUnread.coerceAtLeast(0) + if (current == null || current.readInboxMaxId != update.maxId || current.unreadCount != update.stillUnread.coerceAtLeast( + 0 + ) ) { listPublicationMutex.withLock { dispatch( @@ -320,7 +321,7 @@ internal class ChatsExecutor( is MtprotoUpdate.UnreadMentions -> { val current = state().chats.firstOrNull { it.id == update.chatId } if (current == null || current.unreadMentionsCount != update.stillUnread.coerceAtLeast( - 0 + 0, ) ) { listPublicationMutex.withLock { @@ -332,7 +333,7 @@ internal class ChatsExecutor( is MtprotoUpdate.UnreadReactions -> { val current = state().chats.firstOrNull { it.id == update.chatId } if (current == null || current.unreadReactionsCount != update.stillUnread.coerceAtLeast( - 0 + 0, ) ) { listPublicationMutex.withLock { @@ -372,30 +373,34 @@ internal class ChatsExecutor( } } - is MtprotoUpdate.NotifySettingsChanged -> scope.launch { - if (update.topicId != null) return@launch - if (update.peerKind == PEER_NOTIFY_KIND && update.chatId.value != 0L) { - applyPeerMute(update.chatId, update.muteUntil, update.muteSpecified) - } else { - loadNotifyDefaults(force = true) + is MtprotoUpdate.NotifySettingsChanged -> { + scope.launch { + if (update.topicId != null) return@launch + if (update.peerKind == PEER_NOTIFY_KIND && update.chatId.value != 0L) { + applyPeerMute(update.chatId, update.muteUntil, update.muteSpecified) + } else { + loadNotifyDefaults(force = true) + } } } - else -> Unit + else -> { + Unit + } } - } - .launchIn(scope) - client.sessionLost() - .onEach { + }.launchIn(scope) + client.sessionLost().onEach { // Local sign-out signal; the real cause was logged when it died. dispatch(Msg.Error(TelegramError.sessionInvalidated())) - } - .launchIn(scope) + }.launchIn(scope) } override fun executeIntent(intent: ChatsStore.Intent) { when (intent) { - ChatsStore.Intent.Refresh -> refresh(force = true) + ChatsStore.Intent.Refresh -> { + refresh(force = true) + } + ChatsStore.Intent.LoadMore -> { if (state().query.isNotBlank()) loadMoreSearch() else loadMore() } @@ -410,8 +415,8 @@ internal class ChatsExecutor( // request is still active, defer this initial load until that request releases // the shared loading gate. val wire = wireFolderId(intent.folderId) - val needsInitial = changed && isFolderScopedStream(intent.folderId) && - folderPaging[wire]?.started != true + val needsInitial = + changed && isFolderScopedStream(intent.folderId) && folderPaging[wire]?.started != true if (needsInitial) { dispatch(Msg.HasMore(true)) if (state().loadingMore) { @@ -434,22 +439,34 @@ internal class ChatsExecutor( } } - is ChatsStore.Intent.MarkUnread -> markUnread(intent.chatId, intent.unread) + is ChatsStore.Intent.MarkUnread -> { + markUnread(intent.chatId, intent.unread) + } + is ChatsStore.Intent.QueryChanged -> { dispatch(Msg.Query(intent.value)) startSearch(intent.value, immediate = false) } - ChatsStore.Intent.RetrySearch -> startSearch(state().query, immediate = true) - is ChatsStore.Intent.MarkRead -> markRead(intent.chatIds) + ChatsStore.Intent.RetrySearch -> { + startSearch(state().query, immediate = true) + } + + is ChatsStore.Intent.MarkRead -> { + markRead(intent.chatIds) + } } } - private fun markUnread(chatId: PeerId, unread: Boolean) { + private fun markUnread( + chatId: PeerId, + unread: Boolean, + ) { scope.launch { when (val result = client.markDialogUnread(chatId, unread)) { // The bridge echoes a local DialogUnreadMark update on success. is Outcome.Ok -> Unit + is Outcome.Err -> AppLog.warn("chats", result.telegramError.logLine()) } } @@ -461,21 +478,27 @@ internal class ChatsExecutor( */ private suspend fun loadNotifyDefaults(force: Boolean = false) = notifyDefaultsMutex.withLock { if (notifyDefaultsRefreshed && !force) return@withLock - val loggedOut = sessionStore?.isAuthorized() == false && - client.isLocallyAuthorized() != Outcome.Ok(true) + val loggedOut = + sessionStore?.isAuthorized() == false && client.isLocallyAuthorized() != Outcome.Ok(true) if (loggedOut) return@withLock - val users = notifySettingsFor("users") ?: return@withLock - val chats = notifySettingsFor("chats") ?: return@withLock - val broadcasts = notifySettingsFor("broadcasts") ?: return@withLock - notifyDefaults = NotifyDefaults(users = users, chats = chats, broadcasts = broadcasts) + val (users, chats, broadcasts) = coroutineScope { + val users = async { notifySettingsFor("users") } + val chats = async { notifySettingsFor("chats") } + val broadcasts = async { notifySettingsFor("broadcasts") } + Triple(users.await(), chats.await(), broadcasts.await()) + } + notifyDefaults = NotifyDefaults( + users = users ?: return@withLock, + chats = chats ?: return@withLock, + broadcasts = broadcasts ?: return@withLock, + ) notifyDefaultsLoaded = true notifyDefaultsRefreshed = true // Cached for the next offline start; push decisions read the same copy. notifications?.notifyDefaults = notifyDefaults AppLog.api( "chats", - "notify defaults users=${users.muteUntil} chats=${chats.muteUntil} " + - "broadcasts=${broadcasts.muteUntil}", + "notify defaults users=${users.muteUntil} chats=${chats.muteUntil} " + "broadcasts=${broadcasts.muteUntil}", ) listPublicationMutex.withLock { val current = state().chats @@ -500,7 +523,11 @@ internal class ChatsExecutor( * `account.updateNotifySettings` for one peer: the row and the cache show the new bell * straight away instead of waiting for the next dialog page. */ - private suspend fun applyPeerMute(chatId: PeerId, muteUntil: Int, specified: Boolean) { + private suspend fun applyPeerMute( + chatId: PeerId, + muteUntil: Int, + specified: Boolean, + ) { val updated = listPublicationMutex.withLock { val current = state().chats.firstOrNull { it.id == chatId } ?: return val next = if (specified) { @@ -542,17 +569,21 @@ internal class ChatsExecutor( val wire = wireFolderId(folderId) val archiveFallback = fallbackArchiveFolderId(folderId) return when { - archiveFallback != null -> + archiveFallback != null -> { mainNetworkHasMore || (folderArchivePaging[archiveFallback]?.hasMore ?: true) + } - folderId == null || folderId == MAIN_FOLDER_WIRE_ID -> + folderId == null || folderId == MAIN_FOLDER_WIRE_ID -> { cachedTail.any { it.isMainListRow() } || mainNetworkHasMore + } - folderId == ARCHIVE_FOLDER_ID -> - cachedTail.any { it.isArchiveListRow() } || - (folderPaging[wire]?.hasMore ?: true) + folderId == ARCHIVE_FOLDER_ID -> { + cachedTail.any { it.isArchiveListRow() } || (folderPaging[wire]?.hasMore ?: true) + } - else -> folderPaging[wire]?.hasMore ?: true + else -> { + folderPaging[wire]?.hasMore ?: true + } } } @@ -563,11 +594,15 @@ internal class ChatsExecutor( val chat = state().chats.firstOrNull { it.id == id } ?: return@forEach if (chat.unreadCount <= 0 || chat.lastMessageId <= 0) return@forEach when (val result = client.readHistory(chat.id, chat.lastMessageId)) { - is Outcome.Ok -> listPublicationMutex.withLock { - dispatch(Msg.InboxRead(chat.id, chat.lastMessageId, unread = 0)) + is Outcome.Ok -> { + listPublicationMutex.withLock { + dispatch(Msg.InboxRead(chat.id, chat.lastMessageId, unread = 0)) + } } - is Outcome.Err -> AppLog.warn("chats", result.telegramError.logLine()) + is Outcome.Err -> { + AppLog.warn("chats", result.telegramError.logLine()) + } } } } @@ -589,7 +624,6 @@ internal class ChatsExecutor( val id = store?.readAuthorizedUserId() val cached = id?.let { store.readProfile(it.value) } if (cached != null) dispatch(Msg.Self(cached)) - if (cached?.avatarCacheKey != null && cached.status != null) return@launch when (val result = client.getProfile(PeerId(0))) { is Outcome.Ok -> { dispatch(Msg.Self(result.value)) @@ -599,7 +633,9 @@ internal class ChatsExecutor( } } - is Outcome.Err -> Unit + is Outcome.Err -> { + Unit + } } } } @@ -624,6 +660,7 @@ internal class ChatsExecutor( val hasMemory = state().chats.isNotEmpty() if (!hasMemory) dispatch(Msg.Loading(true)) dispatch(Msg.Error(null)) + catalogJob?.cancel() val generation = ++catalogGeneration scope.launch { var catalogSeed: List? = null @@ -669,16 +706,14 @@ internal class ChatsExecutor( }.filter { it.isMainListRow() } cachedTail = extra + cachedTail } - val loadedMain = first.count { it.isMainListRow() } + - cachedTail.count { it.isMainListRow() } + val loadedMain = + first.count { it.isMainListRow() } + cachedTail.count { it.isMainListRow() } dispatch( Msg.HasMore( cachedTail.any { it.isMainListRow() } || loadedMain < roomMainCount, ), ) - if (!isFolderScopedStream(activeFolderId) && - cachedTail.any { it.isMainListRow() } - ) { + if (!isFolderScopedStream(activeFolderId) && cachedTail.any { it.isMainListRow() }) { loadMore() } pendingReadStates?.let { rows -> @@ -692,12 +727,12 @@ internal class ChatsExecutor( } // The first network page must already carry inherited mutes; a failure here is // retried on the next refresh so a slow RPC never blocks the list. - if (!notifyDefaultsLoaded) loadNotifyDefaults() - var result = getChatsTimed("get") - if (result is Outcome.Err && - state().chats.isNotEmpty() && - !result.telegramError.requiresReauth - ) { + var result = coroutineScope { + val dialogs = async { getChatsTimed("get") } + if (!notifyDefaultsLoaded) loadNotifyDefaults() + dialogs.await() + } + if (result is Outcome.Err && state().chats.isNotEmpty() && !result.telegramError.requiresReauth) { delay(400) result = getChatsTimed("retry") } @@ -705,7 +740,7 @@ internal class ChatsExecutor( is Outcome.Ok -> { AppLog.api( "chats", - "network count=${result.value.size} merge=${hasMemory || cached.isNotEmpty()}" + "network count=${result.value.size} merge=${hasMemory || cached.isNotEmpty()}", ) lastNetworkPage = result.value mainNetworkHasMore = dialogsHasMore(result.value.size) @@ -713,8 +748,7 @@ internal class ChatsExecutor( val mapped = muteChats(result.value) AppLog.api( "chats", - "mute state overrides=${mapped.count { it.muteOverride }} " + - "muted=${mapped.count { it.muted }} of ${mapped.size}", + "mute state overrides=${mapped.count { it.muteOverride }} " + "muted=${mapped.count { it.muted }} of ${mapped.size}", ) // Keep the live-list snapshot, expensive merge, and replace publication // under one lock. A read/update completion cannot land between the @@ -752,9 +786,7 @@ internal class ChatsExecutor( warmup?.upsertChats(if (hidden.isEmpty()) persisted else persisted + hidden) sessionStore?.upsertPeersFromChats(persisted) catalogSeed = mapped - if (!isFolderScopedStream(activeFolderId) && - cachedTail.any { it.isMainListRow() } - ) { + if (!isFolderScopedStream(activeFolderId) && cachedTail.any { it.isMainListRow() }) { loadMore() } val pruned = warmup?.pruneAheadOfLastMessage(pruneRows).orEmpty() @@ -767,15 +799,17 @@ internal class ChatsExecutor( pruned.take(8).forEach { row -> AppLog.api( "chats", - "prune chat=${row.chatId} lastId=${row.lastMessageId} " + - "cachedMax=${row.cachedMaxId} dropped=${row.droppedIds.size} " + - "ids=${row.droppedIds.take(12).joinToString(",")}", + "prune chat=${row.chatId} lastId=${row.lastMessageId} " + "cachedMax=${row.cachedMaxId} dropped=${row.droppedIds.size} " + "ids=${ + row.droppedIds.take(12).joinToString(",") + }", ) } } + PerfLog.event("chats:refresh_complete", "result=ok") } is Outcome.Err -> { + PerfLog.event("chats:refresh_complete", "result=err") AppLog.warn("chats", result.telegramError.logLine()) if (state().chats.isEmpty()) { dispatch(Msg.Error(result.telegramError)) @@ -786,8 +820,18 @@ internal class ChatsExecutor( setRefreshSyncing(false) catalogSeed?.let { seed -> if (generation == catalogGeneration) { - fillDialogCatalog(generation, archived = false, seed = seed) - fillDialogCatalog(generation, archived = true, seed = emptyList()) + catalogJob = scope.launch { + try { + fillDialogCatalog(generation, archived = false, seed = seed) + fillDialogCatalog( + generation, archived = true, seed = emptyList() + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("chats", "catalog loading failed") + } + } } } } catch (error: CancellationException) { @@ -821,8 +865,8 @@ internal class ChatsExecutor( dispatch(Msg.Append(next)) dispatch( Msg.HasMore( - cachedTail.any { it.isArchiveListRow() } || - (folderPaging[ARCHIVE_FOLDER_WIRE_ID]?.hasMore ?: true), + cachedTail.any { it.isArchiveListRow() } || (folderPaging[ARCHIVE_FOLDER_WIRE_ID]?.hasMore + ?: true), ), ) scope.launch { @@ -926,15 +970,15 @@ internal class ChatsExecutor( } else if (fallbackArchiveFolderId == null && !mainNetworkHasMore) { dispatch(Msg.HasMore(false)) return - } else if (fallbackArchiveFolderId != null && - !mainNetworkHasMore && folderArchivePaging[fallbackArchiveFolderId]?.hasMore == false - ) { + } else if (fallbackArchiveFolderId != null && !mainNetworkHasMore && folderArchivePaging[fallbackArchiveFolderId]?.hasMore == false) { dispatch(Msg.HasMore(false)) return } if (state().error?.requiresReauth == true) return - if (fallbackArchiveFolderId == null && !isFolderScopedStream(requestFolderId) && - dialogsPageCursor(lastNetworkPage, failedOffsetPeers) == null + if (fallbackArchiveFolderId == null && !isFolderScopedStream(requestFolderId) && dialogsPageCursor( + lastNetworkPage, + failedOffsetPeers + ) == null ) { dispatch(Msg.HasMore(false)) AppLog.api("chats", "loadMore end") @@ -952,9 +996,8 @@ internal class ChatsExecutor( } val wire = wireFolderId(requestFolderId) val archiveFallback = fallbackArchiveFolderId(requestFolderId) - val useArchiveFallback = archiveFallback != null && - folderArchivePaging[archiveFallback]?.hasMore != false && - (!mainNetworkHasMore || fetched % 2 == 0) + val useArchiveFallback = + archiveFallback != null && folderArchivePaging[archiveFallback]?.hasMore != false && (!mainNetworkHasMore || fetched % 2 == 0) val folderScoped = isFolderScopedStream(requestFolderId) val pageBudget = pagesPerRequest(requestFolderId) val stateful = when { @@ -963,15 +1006,25 @@ internal class ChatsExecutor( else -> null } val cursor = when { - stateful != null && !stateful.started -> Triple(0, 0, 0L) - stateful != null -> dialogsPageCursor( - stateful.page, - failedOffsetPeers, - skipArchived = false, - ) + stateful != null && !stateful.started -> { + Triple(0, 0, 0L) + } + + stateful != null -> { + dialogsPageCursor( + stateful.page, + failedOffsetPeers, + skipArchived = false, + ) + } + + archiveFallback != null && !mainNetworkHasMore -> { + null + } - archiveFallback != null && !mainNetworkHasMore -> null - else -> dialogsPageCursor(lastNetworkPage, failedOffsetPeers) + else -> { + dialogsPageCursor(lastNetworkPage, failedOffsetPeers) + } } if (cursor == null) { if (stateful != null) { @@ -979,9 +1032,12 @@ internal class ChatsExecutor( } else { mainNetworkHasMore = false } - val otherStreamHasMore = archiveFallback != null && - if (useArchiveFallback) mainNetworkHasMore - else folderArchivePaging[archiveFallback]?.hasMore == true + val otherStreamHasMore = + archiveFallback != null && if (useArchiveFallback) { + mainNetworkHasMore + } else { + folderArchivePaging[archiveFallback]?.hasMore == true + } if (otherStreamHasMore) { // One stream ended, but the other still has pages. Continue // the same request rather than declaring the folder exhausted. @@ -990,8 +1046,7 @@ internal class ChatsExecutor( if (archiveFallback != null) { dispatch( Msg.HasMore( - mainNetworkHasMore || - (folderArchivePaging[archiveFallback]?.hasMore == true), + mainNetworkHasMore || (folderArchivePaging[archiveFallback]?.hasMore == true), ), ) } else if (!folderScoped) { @@ -1008,8 +1063,7 @@ internal class ChatsExecutor( val requestFolder = if (useArchiveFallback) ARCHIVE_FOLDER_WIRE_ID else wire AppLog.api( "chats", - "loadMore folder=$requestFolder pages=$fetched/$pageBudget " + - "offsetDate=$date offsetId=$offsetId peer=$peerId", + "loadMore folder=$requestFolder pages=$fetched/$pageBudget " + "offsetDate=$date offsetId=$offsetId peer=$peerId", ) val result = if (useArchiveFallback || folderScoped) { client.loadMoreFolderChats(requestFolder, date, offsetId, peerId) @@ -1042,8 +1096,7 @@ internal class ChatsExecutor( dispatch( Msg.HasMore( if (archiveFallback != null) { - mainNetworkHasMore || - (folderArchivePaging[archiveFallback]?.hasMore == true) + mainNetworkHasMore || (folderArchivePaging[archiveFallback]?.hasMore == true) } else { mainNetworkHasMore }, @@ -1088,7 +1141,10 @@ internal class ChatsExecutor( stateful?.hasMore = false } - error.kind == TelegramError.Kind.Peer -> failedOffsetPeers += peerId + error.kind == TelegramError.Kind.Peer -> { + failedOffsetPeers += peerId + } + else -> { dispatch(Msg.Error(error)) if (stateful != null) { @@ -1099,8 +1155,7 @@ internal class ChatsExecutor( if (archiveFallback != null) { dispatch( Msg.HasMore( - mainNetworkHasMore || - (folderArchivePaging[archiveFallback]?.hasMore == true), + mainNetworkHasMore || (folderArchivePaging[archiveFallback]?.hasMore == true), ), ) } else { @@ -1115,8 +1170,8 @@ internal class ChatsExecutor( } } if (!paged) { - val stillCurrent = requestGeneration == folderSelectionGeneration && - requestFolderId == activeFolderId + val stillCurrent = + requestGeneration == folderSelectionGeneration && requestFolderId == activeFolderId val archiveFallback = fallbackArchiveFolderId(requestFolderId) if (!stillCurrent) { // A replacement folder owns the next loading transition. @@ -1141,18 +1196,15 @@ internal class ChatsExecutor( if (state().loadingMore) dispatch(Msg.LoadingMore(false)) val pendingFolder = pendingFolderInitialLoad pendingFolderInitialLoad = null - if (pendingFolder != null && pendingFolder == activeFolderId && - isFolderScopedStream(pendingFolder) && - folderPaging[wireFolderId(pendingFolder)]?.started != true + if (pendingFolder != null && pendingFolder == activeFolderId && isFolderScopedStream( + pendingFolder + ) && folderPaging[wireFolderId(pendingFolder)]?.started != true ) { dispatch(Msg.HasMore(true)) loadMore() return } - if (activeFolderId == ARCHIVE_FOLDER_ID && - folderPaging[ARCHIVE_FOLDER_WIRE_ID]?.started != true && - !state().loadingMore - ) { + if (activeFolderId == ARCHIVE_FOLDER_ID && folderPaging[ARCHIVE_FOLDER_WIRE_ID]?.started != true && !state().loadingMore) { loadMoreFromNetwork() } } @@ -1175,8 +1227,7 @@ internal class ChatsExecutor( val name = if (named) { sessionStore?.readProfile(userId.value)?.title ?: state().chats.firstOrNull { it.id == userId }?.title - ?: chatMap[userId.value]?.name - ?: "" + ?: chatMap[userId.value]?.name ?: "" } else { "" } @@ -1215,11 +1266,17 @@ internal class ChatsExecutor( private fun archiveKeep(merged: List): Int { val known = merged.count { it.isArchiveListRow() } return when { - activeFolderId == ARCHIVE_FOLDER_ID -> known.coerceAtLeast(ARCHIVE_PAINT_LIMIT) - activeFolderId != null && activeFolderId != MAIN_FOLDER_WIRE_ID -> + activeFolderId == ARCHIVE_FOLDER_ID -> { known.coerceAtLeast(ARCHIVE_PAINT_LIMIT) + } - else -> ARCHIVE_PAINT_LIMIT + activeFolderId != null && activeFolderId != MAIN_FOLDER_WIRE_ID -> { + known.coerceAtLeast(ARCHIVE_PAINT_LIMIT) + } + + else -> { + ARCHIVE_PAINT_LIMIT + } } } @@ -1263,17 +1320,15 @@ internal class ChatsExecutor( hasMoreOverride: Boolean?, ): Boolean { if (activeFolderId == ARCHIVE_FOLDER_ID) { - val archiveHasMore = hasMoreOverride - ?: (folderPaging[ARCHIVE_FOLDER_WIRE_ID]?.hasMore ?: true) + val archiveHasMore = + hasMoreOverride ?: (folderPaging[ARCHIVE_FOLDER_WIRE_ID]?.hasMore ?: true) return tail.any { it.isArchiveListRow() } || archiveHasMore } val loadedMain = first.count { it.isMainListRow() } + tail.count { it.isMainListRow() } // An incoming update does not carry pagination metadata. Preserve the active // network stream's state instead of turning an unfinished stream off. val streamHasMore = hasMoreOverride ?: state().hasMore - return tail.any { it.isMainListRow() } || - streamHasMore || - loadedMain < roomMainCount + return tail.any { it.isMainListRow() } || streamHasMore || loadedMain < roomMainCount } private fun recoverMissingTitles() { @@ -1284,7 +1339,7 @@ internal class ChatsExecutor( val missing = state().chats.firstOrNull { it.id !in attemptedTitleRecovery && isPlaceholderPeerTitle( it.title, - it.id.value + it.id.value, ) } ?: break attemptedTitleRecovery += missing.id @@ -1301,14 +1356,18 @@ internal class ChatsExecutor( val current = listedChat(missing.id) ?: return@withLock null if (!isPlaceholderPeerTitle( current.title, - current.id.value + current.id.value, ) - ) return@withLock null + ) { + return@withLock null + } val next = current.copy( title = profile.title, isChannel = profile.kind == "channel", isGroup = profile.kind == "group" || profile.kind == "chat", - photoCacheKey = profile.avatarCacheKey ?: current.photoCacheKey, + photoCacheKey = if (profile.avatarKnown) profile.avatarCacheKey else profile.avatarCacheKey + ?: current.photoCacheKey, + photoKnown = profile.avatarKnown || profile.avatarCacheKey != null || current.photoKnown, isVerified = profile.isVerified, ) dispatch(Msg.Chats(listOf(next), fromCache = false, replace = false)) @@ -1318,7 +1377,6 @@ internal class ChatsExecutor( } } } - private fun absorbIncoming( message: Message, accountGeneration: Long = 0, @@ -1329,10 +1387,14 @@ internal class ChatsExecutor( val published = listPublicationMutex.withLock { val current = listedChat(message.id.chatId) when { - current == null -> false + current == null -> { + false + } + !current.isShownInChatList() -> { - persistHidden = - current.withIncomingMessage(message, accountGeneration, acceptedOrder) + persistHidden = current.withIncomingMessage( + message, accountGeneration, acceptedOrder + ) true } @@ -1344,7 +1406,9 @@ internal class ChatsExecutor( if (none { it.id == current.id }) add(current) } republishListedLocked( - applyIncomingMessage(listed, message, accountGeneration, acceptedOrder), + applyIncomingMessage( + listed, message, accountGeneration, acceptedOrder + ), fromCache = false, hasMore = null, keep = paintKeep(), @@ -1364,9 +1428,9 @@ internal class ChatsExecutor( stored.withIncomingMessage( message, accountGeneration, - acceptedOrder - ) - ) + acceptedOrder, + ), + ), ) return@launch } @@ -1376,7 +1440,7 @@ internal class ChatsExecutor( allListed() + stored, message, accountGeneration, - acceptedOrder + acceptedOrder, ), fromCache = false, hasMore = null, @@ -1395,19 +1459,20 @@ internal class ChatsExecutor( var pages = 0 while (pages < 500 && generation == catalogGeneration) { coroutineContext.ensureActive() + // Catalog hydration is optional; don't make catch-up yield to another page. + protocolSyncing.first { !it } val (date, offsetId, peerId) = cursor val result = if (archived) { client.loadMoreFolderChats(ARCHIVE_FOLDER_WIRE_ID, date, offsetId, peerId) } else { client.loadMoreChats(date, offsetId, peerId) } + coroutineContext.ensureActive() + if (generation != catalogGeneration) return when (result) { is Outcome.Err -> { val wait = result.telegramError.retryAfterSeconds - if (result.telegramError.kind == TelegramError.Kind.Flood && - wait != null && - wait > 0 - ) { + if (result.telegramError.kind == TelegramError.Kind.Flood && wait != null && wait > 0) { delay(wait.seconds) continue } @@ -1417,11 +1482,18 @@ internal class ChatsExecutor( is Outcome.Ok -> { val mapped = muteChats(result.value) + coroutineContext.ensureActive() + if (generation != catalogGeneration) return if (mapped.isNotEmpty()) warmup?.upsertChats(mapped) + coroutineContext.ensureActive() + if (generation != catalogGeneration) return if (!dialogsHasMore(mapped.size)) { if (!archived) { + val count = warmup?.mainListCount() ?: roomMainCount + coroutineContext.ensureActive() + if (generation != catalogGeneration) return mainNetworkHasMore = false - roomMainCount = warmup?.mainListCount() ?: roomMainCount + roomMainCount = count } return } @@ -1443,9 +1515,11 @@ internal class ChatsExecutor( if (!archived && seed.isNotEmpty() && !dialogsHasMore(seed.size)) { if (saved != null && seedCursor != null && offsetIsOlder( saved, - seedCursor + seedCursor, ) - ) return saved + ) { + return saved + } return null } if (saved != null && (seedCursor == null || offsetIsOlder(saved, seedCursor))) return saved @@ -1457,11 +1531,9 @@ internal class ChatsExecutor( private fun offsetIsOlder( left: Triple, - right: Triple + right: Triple, ): Boolean = - left.first < right.first || - (left.first == right.first && left.second < right.second) || - (left.first == right.first && left.second == right.second && left.third < right.third) + left.first < right.first || (left.first == right.first && left.second < right.second) || (left.first == right.first && left.second == right.second && left.third < right.third) private suspend fun muteChats(chats: List): List { val apply = { @@ -1474,7 +1546,10 @@ internal class ChatsExecutor( } } - private fun startSearch(query: String, immediate: Boolean) { + private fun startSearch( + query: String, + immediate: Boolean, + ) { searchJob?.cancel() searchMoreJob?.cancel() val trimmed = query.trim() @@ -1501,13 +1576,7 @@ internal class ChatsExecutor( private fun loadMoreSearch() { val query = state().query.trim() - if (query.isEmpty() || - query != lastRunSearchQuery || - state().searchLoading || - state().searchLoadingMore || - state().searchError != null || - !state().searchHasMore - ) { + if (query.isEmpty() || query != lastRunSearchQuery || state().searchLoading || state().searchLoadingMore || state().searchError != null || !state().searchHasMore) { return } val gen = searchGen @@ -1517,7 +1586,11 @@ internal class ChatsExecutor( } } - private suspend fun runSearch(query: String, gen: Int, reset: Boolean) { + private suspend fun runSearch( + query: String, + gen: Int, + reset: Boolean, + ) { if (reset) { dispatch(Msg.SearchLoading(true)) searchNextRate = 0 @@ -1606,7 +1679,9 @@ internal class ChatsExecutor( ) } - is Outcome.Err -> contactsErr = contacts.telegramError + is Outcome.Err -> { + contactsErr = contacts.telegramError + } } when (global) { is Outcome.Ok -> { @@ -1614,7 +1689,9 @@ internal class ChatsExecutor( searchNextRate = page.nextRate searchNextPeerId = page.nextPeerId.value searchNextOffsetId = page.nextOffsetId - val existing = if (reset) emptySet() else { + val existing = if (reset) { + emptySet() + } else { state().searchMessages.map { it.id }.toSet() } messages = page.messages.filter { it.id !in existing } @@ -1693,4 +1770,4 @@ private fun onlineDotChanged( val wasOnline = LastSeen.resolve(previousStatus, previousAt, now) == LastSeen.Online val nowOnline = LastSeen.resolve(nextStatus, nextAt, now) == LastSeen.Online return wasOnline != nowOnline -} \ No newline at end of file +} diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsMerge.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsMerge.kt index 27943f54e..e2b041139 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsMerge.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsMerge.kt @@ -10,7 +10,10 @@ import org.monogram.core.models.mergeIncomingMessage import org.monogram.core.models.mergeLocalCache import org.monogram.core.models.preferredPeerTitle -internal fun applyReadStates(chats: List, rows: Map): List { +internal fun applyReadStates( + chats: List, + rows: Map, +): List { // Room invalidates the whole table; apply its snapshot once, copying only on change. // A dialog can clear mentions/reactions while its normal read cursor stays unchanged. var updated: MutableList? = null @@ -21,11 +24,9 @@ internal fun applyReadStates(chats: List, rows: Map): val mentions = row.unreadMentionsCount?.coerceAtLeast(0) ?: chat.unreadMentionsCount val reactions = row.unreadReactionsCount?.coerceAtLeast(0) ?: chat.unreadReactionsCount val readInboxMaxId = if (acceptsReadState) row.readInboxMaxId else chat.readInboxMaxId - if (readInboxMaxId == chat.readInboxMaxId && - unread == chat.unreadCount && - mentions == chat.unreadMentionsCount && - reactions == chat.unreadReactionsCount - ) return@forEachIndexed + if (readInboxMaxId == chat.readInboxMaxId && unread == chat.unreadCount && mentions == chat.unreadMentionsCount && reactions == chat.unreadReactionsCount) { + return@forEachIndexed + } val target = updated ?: chats.toMutableList().also { updated = it } target[index] = chat.copy( readInboxMaxId = readInboxMaxId, @@ -46,18 +47,20 @@ internal fun Chat.isArchiveListRow(): Boolean = isShownInChatList() && archived internal fun sortChats(chats: Collection): List = chats.filter { it.isShownInChatList() }.sortedWith(chatListOrder) -private val chatListOrder = - compareByDescending { it.pinned } - .thenBy { it.pinnedOrder } - .thenByDescending { it.lastMessageDate ?: Long.MIN_VALUE } - .thenByDescending { it.id.value } +private val chatListOrder = compareByDescending { it.pinned }.thenBy { it.pinnedOrder } + .thenByDescending { it.lastMessageDate ?: Long.MIN_VALUE }.thenByDescending { it.id.value } /** Last row in dialog-list order. The next Room page starts strictly after it. */ -internal fun listTail(chats: List, archived: Boolean): Chat? = - chats.filter { if (archived) it.isArchiveListRow() else it.isMainListRow() } - .maxWithOrNull(chatListOrder) +internal fun listTail( + chats: List, + archived: Boolean, +): Chat? = chats.filter { if (archived) it.isArchiveListRow() else it.isMainListRow() } + .maxWithOrNull(chatListOrder) -internal fun mergeChats(current: List, extra: List): List { +internal fun mergeChats( + current: List, + extra: List, +): List { val typing = current.filter { it.typing }.associateBy { it.id.value } val byId = LinkedHashMap() current.forEach { byId[it.id.value] = it } @@ -83,7 +86,6 @@ internal fun mergeChats(current: List, extra: List): List { typingAction = live?.typingAction, pinned = if (stale) prev.pinned else incoming.pinned, pinnedOrder = if (stale) prev.pinnedOrder else pinnedOrder, - photoCacheKey = incoming.photoCacheKey ?: prev?.photoCacheKey, dialogScrollMessageId = incoming.dialogScrollMessageId ?: prev?.dialogScrollMessageId, ) } @@ -91,7 +93,10 @@ internal fun mergeChats(current: List, extra: List): List { } /** Network `getDialogs` pin++ wins over a Room row that only had date order. */ -internal fun withNetworkPinOrder(merged: List, network: List): List { +internal fun withNetworkPinOrder( + merged: List, + network: List, +): List { if (network.isEmpty() || merged.isEmpty()) return merged val pins = HashMap>(network.size) network.forEach { pins[it.id.value] = Triple(it.pinned, it.pinnedOrder, it.lastMessageId) } @@ -106,44 +111,49 @@ internal fun withNetworkPinOrder(merged: List, network: List): List< return changed ?: merged } -internal fun applyEditedMessage(chats: List, message: Message): List = - chats.map { chat -> - if (chat.id == message.id.chatId && chat.lastMessageId == message.id.id) { - chat.copy( - lastMessagePreview = message.chatListPreviewSource(), - lastMessageOutgoing = message.outgoing, - lastMessageSenderName = message.senderName, - lastMessageMediaKind = message.mediaKind, - lastMessageDate = message.date, - lastMediaThumbCacheKey = message.thumbCacheKey ?: chat.lastMediaThumbCacheKey, - ) - } else { - chat - } +internal fun applyEditedMessage( + chats: List, + message: Message, +): List = chats.map { chat -> + if (chat.id == message.id.chatId && chat.lastMessageId == message.id.id) { + chat.copy( + lastMessagePreview = message.chatListPreviewSource(), + lastMessageOutgoing = message.outgoing, + lastMessageSenderName = message.senderName, + lastMessageMediaKind = message.mediaKind, + lastMessageDate = message.date, + lastMediaThumbCacheKey = message.thumbCacheKey, + ) + } else { + chat } +} internal fun applyLatestReplacement( chats: List, chatId: PeerId, next: Message?, -): List = - chats.map { chat -> - if (chat.id != chatId) { - chat - } else { - chat.copy( - lastMessagePreview = next?.chatListPreviewSource(), - lastMessageOutgoing = next?.outgoing ?: false, - lastMessageSenderName = next?.senderName, - lastMessageMediaKind = next?.mediaKind, - lastMessageDate = next?.date, - lastMessageId = next?.id?.id ?: 0, - lastMediaThumbCacheKey = next?.thumbCacheKey, - ) - } +): List = chats.map { chat -> + if (chat.id != chatId) { + chat + } else { + chat.copy( + lastMessagePreview = next?.chatListPreviewSource(), + lastMessageOutgoing = next?.outgoing ?: false, + lastMessageSenderName = next?.senderName, + lastMessageMediaKind = next?.mediaKind, + lastMessageDate = next?.date, + lastMessageId = next?.id?.id ?: 0, + lastMediaThumbCacheKey = next?.thumbCacheKey, + ) } +} -internal fun applyUnreadMentions(chats: List, chatId: PeerId, stillUnread: Int): List { +internal fun applyUnreadMentions( + chats: List, + chatId: PeerId, + stillUnread: Int, +): List { val still = stillUnread.coerceAtLeast(0) return chats.map { chat -> if (chat.id == chatId && chat.unreadMentionsCount != still) { @@ -154,7 +164,11 @@ internal fun applyUnreadMentions(chats: List, chatId: PeerId, stillUnread: } } -internal fun applyUnreadReactions(chats: List, chatId: PeerId, stillUnread: Int): List { +internal fun applyUnreadReactions( + chats: List, + chatId: PeerId, + stillUnread: Int, +): List { val still = stillUnread.coerceAtLeast(0) return chats.map { chat -> if (chat.id == chatId && chat.unreadReactionsCount != still) { @@ -165,7 +179,11 @@ internal fun applyUnreadReactions(chats: List, chatId: PeerId, stillUnread } } -internal fun applyUnreadMentionsDelta(chats: List, chatId: PeerId, delta: Int): List { +internal fun applyUnreadMentionsDelta( + chats: List, + chatId: PeerId, + delta: Int, +): List { if (delta == 0) return chats return chats.map { chat -> if (chat.id == chatId) { @@ -176,7 +194,11 @@ internal fun applyUnreadMentionsDelta(chats: List, chatId: PeerId, delta: } } -internal fun applyUnreadReactionsDelta(chats: List, chatId: PeerId, delta: Int): List { +internal fun applyUnreadReactionsDelta( + chats: List, + chatId: PeerId, + delta: Int, +): List { if (delta == 0) return chats return chats.map { chat -> if (chat.id == chatId) { diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatListAppBar.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatListAppBar.kt index fa9f5bbf8..1f6368dae 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatListAppBar.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatListAppBar.kt @@ -13,10 +13,13 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.CloudDone import androidx.compose.material.icons.outlined.CloudOff @@ -40,15 +43,26 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.monogram.core.common.Outcome +import org.monogram.core.models.PeerId +import org.monogram.core.models.peerAvatarCacheKey +import org.monogram.core.ui.ImageCache import org.monogram.core.ui.components.PeerAvatar +import org.monogram.core.ui.menu.AppMenuDivider import org.monogram.core.ui.menu.AppMenuGroup import org.monogram.core.ui.menu.AppMenuItem import org.monogram.core.ui.menu.AppMenuPopup import org.monogram.core.ui.menu.AppMenuSurface +import org.monogram.core.ui.rememberEnsuredFile +import org.monogram.core.ui.rememberStrippedPreview +import org.monogram.feature.chats.AccountMenuItem +import org.monogram.feature.chats.R import org.monogram.network.bridge.session.ProxyConnectionStatus import org.monogram.network.http.MediaRepository import java.io.File @@ -90,14 +104,20 @@ internal fun ChatsTopBar( markAllReadEnabled: Boolean, proxyStatus: ProxyConnectionStatus = ProxyConnectionStatus.Disabled, proxyStatusLabel: String = "Proxy status", + accountMenuItems: List = emptyList(), + onSwitchAccount: (Long) -> Unit = {}, + onAddAccount: () -> Unit = {}, + accountMenuLabel: String = "Accounts", + addAccountLabel: String = "Add account", ) { // Pinned single-row bar: it never moves, only its surface tone and content change. val containerColor by animateColorAsState( - targetValue = if (atTop) { - MaterialTheme.colorScheme.surface - } else { - MaterialTheme.colorScheme.surfaceContainer - }, + targetValue = + if (atTop) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.surfaceContainer + }, animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(), label = "appBarContainer", ) @@ -127,19 +147,24 @@ internal fun ChatsTopBar( }, actions = { if (proxyStatus != ProxyConnectionStatus.Disabled) { - val icon = when (proxyStatus) { - ProxyConnectionStatus.Connected -> Icons.Outlined.CloudDone - ProxyConnectionStatus.Connecting -> Icons.Outlined.Sync - ProxyConnectionStatus.Disconnected, - ProxyConnectionStatus.Failed -> Icons.Outlined.CloudOff + val icon = + when (proxyStatus) { + ProxyConnectionStatus.Connected -> Icons.Outlined.CloudDone - ProxyConnectionStatus.Disabled -> Icons.Outlined.CloudOff - } - val tint = when (proxyStatus) { - ProxyConnectionStatus.Connected -> MaterialTheme.colorScheme.primary - ProxyConnectionStatus.Connecting -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.error - } + ProxyConnectionStatus.Connecting -> Icons.Outlined.Sync + + ProxyConnectionStatus.Disconnected, + ProxyConnectionStatus.Failed, + -> Icons.Outlined.CloudOff + + ProxyConnectionStatus.Disabled -> Icons.Outlined.CloudOff + } + val tint = + when (proxyStatus) { + ProxyConnectionStatus.Connected -> MaterialTheme.colorScheme.primary + ProxyConnectionStatus.Connecting -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.error + } IconButton(onClick = onOpenProxy) { Icon(icon, contentDescription = proxyStatusLabel, tint = tint) } @@ -162,12 +187,18 @@ internal fun ChatsTopBar( onOpenSettings = onOpenSettings, onOpenProfile = onOpenProfile, onMarkAllRead = onMarkAllRead, + accountMenuItems = accountMenuItems, + onSwitchAccount = onSwitchAccount, + onAddAccount = onAddAccount, + accountMenuLabel = accountMenuLabel, + addAccountLabel = addAccountLabel, ) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = containerColor, - scrolledContainerColor = containerColor, - ), + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + scrolledContainerColor = containerColor, + ), ) } @@ -270,6 +301,36 @@ private fun AppBarSubtitle(text: String?) { } } +@Composable +private fun AccountMenuAvatar(account: AccountMenuItem) { + val repo = account.mediaRepository + if (repo == null) { + PeerAvatar(title = account.title, size = 32.dp) + return + } + val peerId = PeerId(account.userId) + val cacheKey = peerAvatarCacheKey(peerId, account.avatarCacheKey) + val generation by repo.cacheGeneration.collectAsStateWithLifecycle() + val identity = Triple(repo, account.userId, cacheKey) + val preview = rememberStrippedPreview(identity) { repo.peerInlineThumbJpeg(peerId, cacheKey) } + val file = + rememberEnsuredFile( + generation = generation, + identity = identity, + resolve = { repo.cachedFile(cacheKey) }, + ensure = { + when (val result = repo.ensureLocalAvatar(peerId, cacheKey)) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + }, + demand = !account.needsSignIn && !account.unavailable, + ) + ImageCache.AccountScope(repo.cacheRoot) { + PeerAvatar(title = account.title, size = 32.dp, imageFile = file, previewBytes = preview) + } +} + @Composable private fun AccountAvatar( title: String, @@ -283,19 +344,25 @@ private fun AccountAvatar( onOpenSettings: () -> Unit, onOpenProfile: () -> Unit, onMarkAllRead: () -> Unit, + accountMenuItems: List, + onSwitchAccount: (Long) -> Unit, + onAddAccount: () -> Unit, + accountMenuLabel: String, + addAccountLabel: String, ) { var menuOpen by remember { mutableStateOf(false) } Box { Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .combinedClickable( - onClick = onOpenSettings, - onLongClick = { menuOpen = true }, - onLongClickLabel = profileLabel, - ) - .semantics { contentDescription = settingsLabel }, + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .combinedClickable( + onClick = onOpenSettings, + onLongClick = { menuOpen = true }, + onLongClickLabel = accountMenuLabel, + ) + .semantics { contentDescription = settingsLabel }, contentAlignment = Alignment.Center, ) { Box(modifier = Modifier.size(32.dp)) { @@ -315,8 +382,54 @@ private fun AccountAvatar( onDismiss = { menuOpen = false }, alignToAnchorEnd = true, ) { - AppMenuSurface { + AppMenuSurface(scrollState = rememberScrollState()) { + AppMenuGroup { + Text( + text = accountMenuLabel, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + accountMenuItems.forEach { account -> + AppMenuItem( + text = account.title, + supportingText = + when { + account.unavailable -> stringResource(R.string.chats_account_unavailable) + account.needsSignIn -> stringResource(R.string.chats_account_sign_in_required) + else -> null + }, + enabled = !account.unavailable, + selected = account.active, + leading = { + if (account.active) { + PeerAvatar( + title = title, + size = 32.dp, + imageFile = imageFile, + previewBytes = previewBytes, + ) + } else { + AccountMenuAvatar(account) + } + }, + onClick = { + menuOpen = false + if (!account.active) onSwitchAccount(account.userId) + }, + ) + } + } + AppMenuDivider() AppMenuGroup { + AppMenuItem( + text = addAccountLabel, + icon = Icons.Outlined.Add, + onClick = { + menuOpen = false + onAddAccount() + }, + ) AppMenuItem( text = profileLabel, icon = Icons.Outlined.Person, diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatRows.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatRows.kt index f37caf0c7..3de93951c 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatRows.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatRows.kt @@ -154,54 +154,56 @@ internal data class ChatListTexts( val story: String, val liveStream: String, ) { - fun mediaLabel(kind: String): String? = when (kind) { - "photo" -> photo - "video" -> video - "gif" -> gif - "sticker", "sticker_animated", "sticker_video" -> sticker - "document" -> document - "audio" -> audio - "voice" -> voice - "video_note" -> video - "webpage" -> link - "todo" -> checklist - "giveaway" -> giveaway - "giveaway_results" -> giveawayResults - "paid_media" -> paidMedia - "game" -> game - "invoice" -> invoice - "story" -> story - "video_stream" -> liveStream - else -> chatListMediaLabel(kind) - } + fun mediaLabel(kind: String): String? = + when (kind) { + "photo" -> photo + "video" -> video + "gif" -> gif + "sticker", "sticker_animated", "sticker_video" -> sticker + "document" -> document + "audio" -> audio + "voice" -> voice + "video_note" -> video + "webpage" -> link + "todo" -> checklist + "giveaway" -> giveaway + "giveaway_results" -> giveawayResults + "paid_media" -> paidMedia + "game" -> game + "invoice" -> invoice + "story" -> story + "video_stream" -> liveStream + else -> chatListMediaLabel(kind) + } } @Composable -internal fun chatListTexts(): ChatListTexts = ChatListTexts( - you = stringResource(R.string.chats_preview_you), - someone = stringResource(org.monogram.core.ui.R.string.service_someone), - untitled = stringResource(R.string.chats_untitled), - savedMessages = stringResource(R.string.chats_saved_messages), - muted = stringResource(R.string.chats_muted), - pinned = stringResource(R.string.chats_pinned), - yesterday = stringResource(R.string.chats_yesterday), - photo = stringResource(R.string.chats_preview_photo), - video = stringResource(R.string.chats_preview_video), - gif = stringResource(R.string.chats_preview_gif), - sticker = stringResource(R.string.chats_preview_sticker), - document = stringResource(R.string.chats_preview_document), - audio = stringResource(R.string.chats_preview_audio), - voice = stringResource(R.string.chats_preview_voice), - link = stringResource(R.string.chats_preview_link), - checklist = stringResource(R.string.chats_preview_checklist), - giveaway = stringResource(R.string.chats_preview_giveaway), - giveawayResults = stringResource(R.string.chats_preview_giveaway_results), - paidMedia = stringResource(R.string.chats_preview_paid_media), - game = stringResource(R.string.chats_preview_game), - invoice = stringResource(R.string.chats_preview_invoice), - story = stringResource(R.string.chats_preview_story), - liveStream = stringResource(R.string.chats_preview_live_stream), -) +internal fun chatListTexts(): ChatListTexts = + ChatListTexts( + you = stringResource(R.string.chats_preview_you), + someone = stringResource(org.monogram.core.ui.R.string.service_someone), + untitled = stringResource(R.string.chats_untitled), + savedMessages = stringResource(R.string.chats_saved_messages), + muted = stringResource(R.string.chats_muted), + pinned = stringResource(R.string.chats_pinned), + yesterday = stringResource(R.string.chats_yesterday), + photo = stringResource(R.string.chats_preview_photo), + video = stringResource(R.string.chats_preview_video), + gif = stringResource(R.string.chats_preview_gif), + sticker = stringResource(R.string.chats_preview_sticker), + document = stringResource(R.string.chats_preview_document), + audio = stringResource(R.string.chats_preview_audio), + voice = stringResource(R.string.chats_preview_voice), + link = stringResource(R.string.chats_preview_link), + checklist = stringResource(R.string.chats_preview_checklist), + giveaway = stringResource(R.string.chats_preview_giveaway), + giveawayResults = stringResource(R.string.chats_preview_giveaway_results), + paidMedia = stringResource(R.string.chats_preview_paid_media), + game = stringResource(R.string.chats_preview_game), + invoice = stringResource(R.string.chats_preview_invoice), + story = stringResource(R.string.chats_preview_story), + liveStream = stringResource(R.string.chats_preview_live_stream), + ) /** * Archive is a service header above the chats, not a conversation: the leading icon shares the @@ -218,27 +220,29 @@ internal fun ArchiveRow( ) { val unreadTotal = unmuted + mutedUnread Row( - modifier = modifier - .fillMaxWidth() - .heightIn(min = ChatRowHeight) - .padding( - horizontal = ChatRowSideInset, - vertical = ChatRowMetrics.ContainerPaddingV, - ) - .clip(RoundedCornerShape(ChatRowCorner)) - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .clickable(onClick = onClick) - .padding( - horizontal = ChatRowMetrics.ContentPaddingH, - vertical = ChatRowMetrics.ContentPaddingV, - ), + modifier = + modifier + .fillMaxWidth() + .heightIn(min = ChatRowHeight) + .padding( + horizontal = ChatRowSideInset, + vertical = ChatRowMetrics.ContainerPaddingV, + ) + .clip(RoundedCornerShape(ChatRowCorner)) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .clickable(onClick = onClick) + .padding( + horizontal = ChatRowMetrics.ContentPaddingH, + vertical = ChatRowMetrics.ContentPaddingV, + ), verticalAlignment = Alignment.CenterVertically, ) { Box( - modifier = Modifier - .size(ChatRowAvatarSize) - .clip(RoundedCornerShape(18.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), + modifier = + Modifier + .size(ChatRowAvatarSize) + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center, ) { Icon( @@ -303,37 +307,40 @@ internal fun ChatRow( onLongClick: (() -> Unit)? = null, ) { RecompositionProbe("ChatRow", chat.id.value) - val time = remember(chat.lastMessageDate, texts.yesterday) { - formatChatTime(chat.lastMessageDate, yesterdayLabel = texts.yesterday) - } - val outgoingMark = remember( - showReadStatus, - savedMessages, - chat.lastMessageOutgoing, - chat.isChannel, - chat.lastMessageId, - chat.readInboxMaxId, - chat.readOutboxMaxId, - chat.unreadCount, - ) { - if (!showReadStatus || savedMessages) { - null - } else { - Presence.chatListOutgoingMark( - outgoing = chat.lastMessageOutgoing, - isChannel = chat.isChannel, - lastMessageId = chat.lastMessageId, - readInboxMaxId = chat.readInboxMaxId, - readOutboxMaxId = chat.readOutboxMaxId, - unreadCount = chat.unreadCount, - ) + val time = + remember(chat.lastMessageDate, texts.yesterday) { + formatChatTime(chat.lastMessageDate, yesterdayLabel = texts.yesterday) + } + val outgoingMark = + remember( + showReadStatus, + savedMessages, + chat.lastMessageOutgoing, + chat.isChannel, + chat.lastMessageId, + chat.readInboxMaxId, + chat.readOutboxMaxId, + chat.unreadCount, + ) { + if (!showReadStatus || savedMessages) { + null + } else { + Presence.chatListOutgoingMark( + outgoing = chat.lastMessageOutgoing, + isChannel = chat.isChannel, + lastMessageId = chat.lastMessageId, + readInboxMaxId = chat.readInboxMaxId, + readOutboxMaxId = chat.readOutboxMaxId, + unreadCount = chat.unreadCount, + ) + } + } + val title = + when { + savedMessages -> texts.savedMessages + chat.title.isNotBlank() -> chat.title + else -> texts.untitled } - } - val title = when { - savedMessages -> texts.savedMessages - chat.title.isNotBlank() -> chat.title - else -> texts.untitled - } val unread = chat.unreadCount val loud = unread > 0 && !chat.muted ChatRowContainer( @@ -345,15 +352,17 @@ internal fun ChatRow( modifier = modifier, ) { if (showAvatar) { - val avatarClick = if (selectingRecipient) { - null - } else { - onAvatarClick ?: onClick - } + val avatarClick = + if (selectingRecipient) { + null + } else { + onAvatarClick ?: onClick + } Box( - modifier = Modifier - .size(ChatRowAvatarSize) - .then(if (avatarClick != null) Modifier.clickable(onClick = avatarClick) else Modifier), + modifier = + Modifier + .size(ChatRowAvatarSize) + .then(if (avatarClick != null) Modifier.clickable(onClick = avatarClick) else Modifier), ) { if (savedMessages) { ServiceAvatar( @@ -365,15 +374,17 @@ internal fun ChatRow( PeerAvatar( title = title, size = ChatRowAvatarSize, - imageFile = rememberChatAvatar( - chat = chat, - mediaRepository = mediaRepository, - ), - previewBytes = rememberStrippedPreview( - identity = chat.id, - ) { - mediaRepository?.peerInlineThumbJpeg(chat.id) - }, + imageFile = + rememberChatAvatar( + chat = chat, + mediaRepository = mediaRepository, + ), + previewBytes = + rememberStrippedPreview( + identity = Triple(mediaRepository, chat.id, chat.photoCacheKey), + ) { + mediaRepository?.peerInlineThumbJpeg(chat.id, chat.photoCacheKey) + }, ) } if (!savedMessages) { @@ -392,26 +403,29 @@ internal fun ChatRow( ) { Text( text = title, - style = if (loud) { - MaterialTheme.typography.titleMediumEmphasized - } else { - MaterialTheme.typography.titleMedium - }, + style = + if (loud) { + MaterialTheme.typography.titleMediumEmphasized + } else { + MaterialTheme.typography.titleMedium + }, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f, fill = false) - .heightIn(min = ChatRowTitleLineHeight), + modifier = + Modifier + .weight(1f, fill = false) + .heightIn(min = ChatRowTitleLineHeight), ) if (chat.isVerified) { Icon( imageVector = Icons.Outlined.Check, contentDescription = stringResource(R.string.chats_verified), tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .offset(y = -ChatTitleMarkLift) - .padding(start = ChatTitleMarkGap) - .size(ChatMutedMarkSize), + modifier = + Modifier + .offset(y = -ChatTitleMarkLift) + .padding(start = ChatTitleMarkGap) + .size(ChatMutedMarkSize), ) } SponsorBadge( @@ -429,9 +443,10 @@ internal fun ChatRow( Icon( imageVector = Icons.Outlined.NotificationsOff, contentDescription = texts.muted, - modifier = Modifier - .size(ChatMutedMarkSize) - .offset(y = -ChatTitleMarkLift), + modifier = + Modifier + .size(ChatMutedMarkSize) + .offset(y = -ChatTitleMarkLift), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -485,15 +500,17 @@ internal fun ChatRow( UnreadBadge( count = unread, muted = chat.muted, - description = if (unread > 0) { - pluralStringResource(R.plurals.chats_unread_count, unread, unread) - } else { - null - }, + description = + if (unread > 0) { + pluralStringResource(R.plurals.chats_unread_count, unread, unread) + } else { + null + }, ) } - if (chat.unreadMark && unread == 0 && !showsMentionBadge(chat) && !showsReactionBadge( - chat + if (chat.unreadMark && unread == 0 && !showsMentionBadge(chat) && + !showsReactionBadge( + chat, ) ) { UnreadMarkDot( @@ -507,14 +524,16 @@ internal fun ChatRow( checked = selected, onCheckedChange = null, enabled = recipientSelectable, - modifier = Modifier - .size(48.dp) - .clearAndSetSemantics { }, - colors = CheckboxDefaults.colors( - checkedColor = MaterialTheme.colorScheme.primary, - uncheckedColor = MaterialTheme.colorScheme.onSurfaceVariant, - checkmarkColor = MaterialTheme.colorScheme.onPrimary, - ), + modifier = + Modifier + .size(48.dp) + .clearAndSetSemantics { }, + colors = + CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.primary, + uncheckedColor = MaterialTheme.colorScheme.onSurfaceVariant, + checkmarkColor = MaterialTheme.colorScheme.onPrimary, + ), ) } } @@ -525,17 +544,22 @@ internal fun showsMentionBadge(chat: Chat): Boolean = chat.unreadMentionsCount > internal fun showsReactionBadge(chat: Chat): Boolean = chat.unreadReactionsCount > 0 @Composable -private fun MentionBadge(muted: Boolean, description: String) { +private fun MentionBadge( + muted: Boolean, + description: String, +) { val scheme = MaterialTheme.colorScheme val container = if (muted) scheme.surfaceVariant else scheme.primary - val content = contentColorFor(container).takeIf { it != Color.Unspecified } - ?: if (muted) scheme.onSurfaceVariant else scheme.onPrimary + val content = + contentColorFor(container).takeIf { it != Color.Unspecified } + ?: if (muted) scheme.onSurfaceVariant else scheme.onPrimary Box( - modifier = Modifier - .clip(CircleShape) - .background(container) - .padding(horizontal = 7.dp, vertical = 2.dp) - .semantics { contentDescription = description }, + modifier = + Modifier + .clip(CircleShape) + .background(container) + .padding(horizontal = 7.dp, vertical = 2.dp) + .semantics { contentDescription = description }, contentAlignment = Alignment.Center, ) { Text( @@ -547,17 +571,22 @@ private fun MentionBadge(muted: Boolean, description: String) { } @Composable -private fun ReactionBadge(muted: Boolean, description: String) { +private fun ReactionBadge( + muted: Boolean, + description: String, +) { val scheme = MaterialTheme.colorScheme val container = if (muted) scheme.surfaceVariant else scheme.primary - val content = contentColorFor(container).takeIf { it != Color.Unspecified } - ?: if (muted) scheme.onSurfaceVariant else scheme.onPrimary + val content = + contentColorFor(container).takeIf { it != Color.Unspecified } + ?: if (muted) scheme.onSurfaceVariant else scheme.onPrimary Box( - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(container) - .semantics { contentDescription = description }, + modifier = + Modifier + .size(24.dp) + .clip(CircleShape) + .background(container) + .semantics { contentDescription = description }, contentAlignment = Alignment.Center, ) { Icon( @@ -574,15 +603,19 @@ private fun ReactionBadge(muted: Boolean, description: String) { * dot instead of a counter for it. */ @Composable -private fun UnreadMarkDot(muted: Boolean, description: String) { +private fun UnreadMarkDot( + muted: Boolean, + description: String, +) { Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background( - if (muted) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primary, - ) - .semantics { contentDescription = description }, + modifier = + Modifier + .size(10.dp) + .clip(CircleShape) + .background( + if (muted) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primary, + ) + .semantics { contentDescription = description }, ) } @@ -597,42 +630,47 @@ private fun ChatRowContainer( onLongClick: (() -> Unit)? = null, content: @Composable RowScope.() -> Unit, ) { - val clickModifier = if (selectingRecipient) { - Modifier.toggleable( - value = selected, - enabled = enabled, - role = Role.Checkbox, - onValueChange = { onClick() }, - ) - } else { - Modifier.combinedClickable( - onClick = onClick, - onLongClick = onLongClick, - ) - } - Row( - modifier = modifier - .fillMaxWidth() - .heightIn(min = ChatRowHeight) - .padding( - horizontal = ChatRowSideInset, - vertical = ChatRowMetrics.ContainerPaddingV, + val clickModifier = + if (selectingRecipient) { + Modifier.toggleable( + value = selected, + enabled = enabled, + role = Role.Checkbox, + onValueChange = { onClick() }, ) - .clip(RoundedCornerShape(ChatRowCorner)) - .background( - if (selected) { - if (selectingRecipient) MaterialTheme.colorScheme.secondaryContainer - else MaterialTheme.colorScheme.surfaceContainerHighest - } else { - Color.Transparent - }, + } else { + Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, ) - .then(clickModifier) - .semantics { if (!selectingRecipient) this.selected = selected } - .padding( - horizontal = ChatRowMetrics.ContentPaddingH, - vertical = ChatRowMetrics.ContentPaddingV, - ), + } + Row( + modifier = + modifier + .fillMaxWidth() + .heightIn(min = ChatRowHeight) + .padding( + horizontal = ChatRowSideInset, + vertical = ChatRowMetrics.ContainerPaddingV, + ) + .clip(RoundedCornerShape(ChatRowCorner)) + .background( + if (selected) { + if (selectingRecipient) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + } + } else { + Color.Transparent + }, + ) + .then(clickModifier) + .semantics { if (!selectingRecipient) this.selected = selected } + .padding( + horizontal = ChatRowMetrics.ContentPaddingH, + vertical = ChatRowMetrics.ContentPaddingV, + ), verticalAlignment = Alignment.CenterVertically, ) { content() @@ -646,11 +684,12 @@ private fun ChatPreviewLine( mediaRepository: MediaRepository?, loud: Boolean, ) { - val bodyColor = if (loud) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + val bodyColor = + if (loud) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } AnimatedContent( targetState = chat, transitionSpec = { liveActionTransition() }, @@ -659,10 +698,11 @@ private fun ChatPreviewLine( ?: "p:${row.lastMessageId}:${row.lastMessagePreview}:${row.lastMessageMediaKind}:${row.lastMessageOutgoing}:${row.lastMessageSenderName}" }, label = "chat-preview", - modifier = Modifier - .fillMaxWidth() - .heightIn(min = ChatRowPreviewLineHeight) - .clipToBounds(), + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = ChatRowPreviewLineHeight) + .clipToBounds(), ) { row -> val action = row.typingAction if (action != null) { @@ -700,13 +740,14 @@ private fun ChatPreviewLine( modifier = Modifier.size(16.dp), ) } - val preview = formatPreviewText( - chat = row, - youLabel = texts.you, - someoneLabel = texts.someone, - mediaLabel = texts::mediaLabel, - serviceTemplate = serviceMessageCatalog(LocalResources.current), - ) + val preview = + formatPreviewText( + chat = row, + youLabel = texts.you, + someoneLabel = texts.someone, + mediaLabel = texts::mediaLabel, + serviceTemplate = serviceMessageCatalog(LocalResources.current), + ) val (sender, body) = splitPreviewSender(preview, texts.you) if (sender != null) { Text( @@ -775,13 +816,14 @@ private fun ChatTimeLabel( @Composable internal fun OnlineDot(modifier: Modifier = Modifier) { Box( - modifier = modifier - .size(12.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface) - .padding(2.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), + modifier = + modifier + .size(12.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .padding(2.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), ) } @@ -790,15 +832,16 @@ private fun rememberChatAvatar( chat: Chat, mediaRepository: MediaRepository?, ) = rememberEnsuredFile( - generation = rememberCacheGeneration( - remember(mediaRepository, chat.id, chat.photoCacheKey) { - mediaRepository?.cacheGeneration(peerAvatarCacheKey(chat.id, chat.photoCacheKey)) - }, - ), + generation = + rememberCacheGeneration( + remember(mediaRepository, chat.id, chat.photoCacheKey) { + mediaRepository?.cacheGeneration(peerAvatarCacheKey(chat.id, chat.photoCacheKey)) + }, + ), identity = chat.id.value to peerAvatarCacheKey(chat.id, chat.photoCacheKey), resolve = { val key = peerAvatarCacheKey(chat.id, chat.photoCacheKey) - mediaRepository?.cachedFile(key) ?: mediaRepository?.cachedAvatar(chat.id) + mediaRepository?.cachedFile(key) }, ensure = { val repo = mediaRepository ?: return@rememberEnsuredFile null @@ -811,7 +854,10 @@ private fun rememberChatAvatar( ) @Composable -private fun ChatOnlineDot(chat: Chat, modifier: Modifier) { +private fun ChatOnlineDot( + chat: Chat, + modifier: Modifier, +) { if (chat.isChannel || chat.isGroup) return val live by PeerPresence.flow(chat.id.value).collectAsState() val status = live?.status ?: chat.peerStatus @@ -826,61 +872,78 @@ private fun LastMessageThumb( mediaRepository: MediaRepository?, ) { val key = chat.lastMediaThumbCacheKey ?: return - val file = rememberEnsuredFile( - generation = rememberCacheGeneration( - remember(mediaRepository, key) { mediaRepository?.cacheGeneration(key) }, - ), - identity = chat.id.value to key, - resolve = { mediaRepository?.cachedFile(key) }, - ensure = { - val repo = mediaRepository ?: return@rememberEnsuredFile null - if (chat.lastMessageId == 0) return@rememberEnsuredFile null - val probe = org.monogram.core.models.Message( - id = org.monogram.core.models.MessageId(chat.id, chat.lastMessageId), - senderId = null, - text = null, - date = 0L, - outgoing = false, - mediaCacheKey = key, - thumbCacheKey = key, - ) - when (val result = repo.ensureLocalMessageThumb(probe, MediaPriority.THUMB)) { - is Outcome.Ok -> result.value - is Outcome.Err -> null - } - }, - ) + val file = + rememberEnsuredFile( + generation = + rememberCacheGeneration( + remember(mediaRepository, key) { mediaRepository?.cacheGeneration(key) }, + ), + identity = chat.id.value to key, + resolve = { mediaRepository?.cachedFile(key) }, + ensure = { + val repo = mediaRepository ?: return@rememberEnsuredFile null + if (chat.lastMessageId == 0) return@rememberEnsuredFile null + val probe = + org.monogram.core.models.Message( + id = + org.monogram.core.models + .MessageId(chat.id, chat.lastMessageId), + senderId = null, + text = null, + date = 0L, + outgoing = false, + mediaCacheKey = key, + thumbCacheKey = key, + ) + when (val result = repo.ensureLocalMessageThumb(probe, MediaPriority.THUMB)) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + }, + ) val local = file ?: return val context = LocalContext.current val sizePx = with(LocalDensity.current) { ChatRowThumbSize.roundToPx() } AsyncImage( - model = ImageRequest.Builder(context).data(local).size(sizePx) - .memoryCacheKey("${local.absolutePath}:${local.length()}:$sizePx") - .diskCacheKey("${local.absolutePath}:${local.length()}").crossfade(false).build(), + model = + ImageRequest + .Builder(context) + .data(local) + .size(sizePx) + .memoryCacheKey("${local.absolutePath}:${local.length()}:$sizePx") + .diskCacheKey("${local.absolutePath}:${local.length()}") + .crossfade(false) + .build(), contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier - .size(ChatRowThumbSize) - .clip(RoundedCornerShape(12.dp)), + modifier = + Modifier + .size(ChatRowThumbSize) + .clip(RoundedCornerShape(12.dp)), ) } /** Thumbnails only exist for visual media with a cached small preview. */ -private fun thumbAvailable(chat: Chat, media: ChatPreviewMedia?): Boolean = - (media == ChatPreviewMedia.Photo || media == ChatPreviewMedia.Video || media == ChatPreviewMedia.Gif) && chat.lastMessageId != 0 && !chat.lastMediaThumbCacheKey.isNullOrBlank() +private fun thumbAvailable( + chat: Chat, + media: ChatPreviewMedia?, +): Boolean = + (media == ChatPreviewMedia.Photo || media == ChatPreviewMedia.Video || media == ChatPreviewMedia.Gif) && chat.lastMessageId != 0 && + !chat.lastMediaThumbCacheKey.isNullOrBlank() private val ChatPreviewMedia.icon: ImageVector - get() = when (this) { - ChatPreviewMedia.Photo -> Icons.Outlined.Image - ChatPreviewMedia.Video -> Icons.Outlined.Videocam - ChatPreviewMedia.Gif -> Icons.Outlined.Gif - ChatPreviewMedia.Sticker -> Icons.Outlined.EmojiEmotions - ChatPreviewMedia.Voice -> Icons.Outlined.Mic - ChatPreviewMedia.Audio -> Icons.Outlined.MusicNote - ChatPreviewMedia.Document -> Icons.AutoMirrored.Outlined.InsertDriveFile - ChatPreviewMedia.Link -> Icons.Outlined.Link - ChatPreviewMedia.Checklist -> Icons.Outlined.Checklist - } + get() = + when (this) { + ChatPreviewMedia.Photo -> Icons.Outlined.Image + ChatPreviewMedia.Video -> Icons.Outlined.Videocam + ChatPreviewMedia.Gif -> Icons.Outlined.Gif + ChatPreviewMedia.Sticker -> Icons.Outlined.EmojiEmotions + ChatPreviewMedia.Voice -> Icons.Outlined.Mic + ChatPreviewMedia.Audio -> Icons.Outlined.MusicNote + ChatPreviewMedia.Document -> Icons.AutoMirrored.Outlined.InsertDriveFile + ChatPreviewMedia.Link -> Icons.Outlined.Link + ChatPreviewMedia.Checklist -> Icons.Outlined.Checklist + } @Composable internal fun ChatEmojiStatus( @@ -889,20 +952,22 @@ internal fun ChatEmojiStatus( ) { val id = documentId ?: return val key = "emoji:$id" - val file = rememberEnsuredFile( - generation = rememberCacheGeneration( - remember(mediaRepository, key) { mediaRepository?.cacheGeneration(key) }, - ), - identity = id, - resolve = { mediaRepository?.cachedFile(key) }, - ensure = { - val repo = mediaRepository ?: return@rememberEnsuredFile null - when (val result = repo.ensureCustomEmoji(id, MediaPriority.IDLE)) { - is Outcome.Ok -> result.value - is Outcome.Err -> null - } - }, - ) + val file = + rememberEnsuredFile( + generation = + rememberCacheGeneration( + remember(mediaRepository, key) { mediaRepository?.cacheGeneration(key) }, + ), + identity = id, + resolve = { mediaRepository?.cachedFile(key) }, + ensure = { + val repo = mediaRepository ?: return@rememberEnsuredFile null + when (val result = repo.ensureCustomEmoji(id, MediaPriority.IDLE)) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + }, + ) EmojiStatusMark( file = file, size = 18.dp, diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsContent.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsContent.kt index d8c3787f4..579d989d8 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsContent.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsContent.kt @@ -63,6 +63,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext @@ -78,6 +79,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome +import org.monogram.core.common.PerfLog import org.monogram.core.common.telegram.TelegramError import org.monogram.core.models.ARCHIVE_FOLDER_ID import org.monogram.core.models.Chat @@ -143,6 +145,7 @@ fun ChatsContent( selectionBottomBar: @Composable () -> Unit = {}, ) { val state = collectWhenActive(component.state, listActive) + val startupDraws = remember(component) { BooleanArray(2) } var proxyStatus by remember { mutableStateOf(component.proxyConnectionStatus()) } LaunchedEffect(component, listActive) { while (isActive) { @@ -162,7 +165,7 @@ fun ChatsContent( Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ChatListSkeleton( itemCount = DIALOGS_PAINT_LIMIT, - showAvatar = appearance.showChatAvatars + showAvatar = appearance.showChatAvatars, ) } return @@ -173,7 +176,7 @@ fun ChatsContent( val defaultFolder = remember(folders, appearance.showAllChats) { defaultFolderId( folders, - appearance.showAllChats + appearance.showAllChats, ) } var archiveOpen by rememberSaveable { mutableStateOf(false) } @@ -270,9 +273,8 @@ fun ChatsContent( LaunchedEffect(defaultFolder, folders, archiveOpen, appearance.showAllChats) { if (archiveOpen) return@LaunchedEffect val current = selectedFolderId - val missing = (current != null && current != ARCHIVE_FOLDER_ID && - folders.isNotEmpty() && folders.none { it.id == current }) || - (current == null && !appearance.showAllChats && defaultFolder != null) + val missing = + (current != null && current != ARCHIVE_FOLDER_ID && folders.isNotEmpty() && folders.none { it.id == current }) || (current == null && !appearance.showAllChats && defaultFolder != null) if (missing) { selectedFolderId = defaultFolder folderChoiceMade = defaultFolder != null @@ -310,8 +312,11 @@ fun ChatsContent( if (searchFocusRequest <= 0) return@LaunchedEffect // Wait for the search field to enter the composition before focusing it. repeat(2) { withFrameNanos { } } - runCatching { searchFocus.requestFocus() } - .onFailure { AppLog.warn("chats", "search focus unavailable") } + runCatching { searchFocus.requestFocus() }.onFailure { + AppLog.warn( + "chats", "search focus unavailable" + ) + } } val allChatsLabel = stringResource(R.string.chats_folder_all) @@ -355,8 +360,8 @@ fun ChatsContent( shownChats.replace( filterChats( visibleChats(state.chats, folders, homeFolderId), - state.query - ) + state.query, + ), ) } val archivedSlice = remember(state.chats) { @@ -397,10 +402,24 @@ fun ChatsContent( val paneChats = if (archive) archivedChats else shownChats val paneListState = if (archive) archiveListState else (primaryListState ?: listState) val paneEmpty = paneChats.isEmpty + val measuredModifier = if (PerfLog.isEnabled() && listActive && !selectingRecipient) { + Modifier.drawWithContent { + drawContent() + if (!paneEmpty && !startupDraws[0]) { + startupDraws[0] = true + PerfLog.event("startup:chats_draw") + } + if (!startupDraws[1] && !paneEmpty && !state.fromCache && !state.loading && !state.syncing && state.error == null) { + startupDraws[1] = true + PerfLog.event("startup:chats_settled_draw") + } + } + } else { + Modifier + } val atTop by remember(paneListState) { derivedStateOf { - paneListState.firstVisibleItemIndex == 0 && - paneListState.firstVisibleItemScrollOffset <= AtTopTolerance + paneListState.firstVisibleItemIndex == 0 && paneListState.firstVisibleItemScrollOffset <= AtTopTolerance } } val rawSync = when { @@ -419,14 +438,21 @@ fun ChatsContent( else -> null } val subtitle = when { - overlayTitle != null -> null - state.error?.kind == TelegramError.Kind.Network -> + overlayTitle != null -> { + null + } + + state.error?.kind == TelegramError.Kind.Network -> { stringResource(R.string.chats_waiting_network) + } - unreadChats > 0 -> + unreadChats > 0 -> { pluralStringResource(R.plurals.chats_unread_chats, unreadChats, unreadChats) + } - else -> null + else -> { + null + } } val folderTitle = when { archive -> archiveLabel @@ -436,7 +462,7 @@ fun ChatsContent( val markAllIds = remember(state.chats, archive) { unreadChatIds(state.chats, archive) } Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = measuredModifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.surface, contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { @@ -486,8 +512,19 @@ fun ChatsContent( self = state.self, mediaRepository = component.mediaRepository, ), - selfPreview = rememberStrippedPreview(state.self?.id) { - state.self?.id?.let { component.mediaRepository?.peerInlineThumbJpeg(it) } + selfPreview = rememberStrippedPreview( + Triple( + component.mediaRepository, + state.self?.id, + state.self?.avatarCacheKey, + ), + ) { + state.self?.id?.let { + component.mediaRepository?.peerInlineThumbJpeg( + it, + state.self?.avatarCacheKey, + ) + } }, selfOnline = selfOnline, mediaRepository = component.mediaRepository, @@ -505,6 +542,18 @@ fun ChatsContent( settingsLabel = stringResource(R.string.chats_settings), markAllReadLabel = markAllReadLabel, markAllReadEnabled = markAllIds.isNotEmpty(), + accountMenuItems = component.accountMenuItems.map { account -> + state.self?.takeIf { + account.active && account.title == stringResource( + R.string.chats_account_fallback, + account.userId, + ) && it.id.value == account.userId && it.title.isNotBlank() + }?.let { account.copy(title = it.title) } ?: account + }, + onSwitchAccount = component::switchAccount, + onAddAccount = component::addAccount, + accountMenuLabel = stringResource(R.string.chats_accounts), + addAccountLabel = stringResource(R.string.chats_add_account), proxyStatus = proxyStatus, proxyStatusLabel = stringResource(R.string.chats_proxy_status), ) @@ -546,9 +595,11 @@ fun ChatsContent( component::onRefresh }, ) - Column(modifier = Modifier - .weight(1f) - .fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + ) { if (!archive && !searchOpen && !foldersAtBottom && showFolderChips) { Box { FolderChipRow( @@ -577,9 +628,11 @@ fun ChatsContent( } } } - Box(modifier = Modifier - .weight(1f) - .fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + ) { androidx.compose.animation.AnimatedVisibility( visible = state.loading && paneEmpty && !folderMoving, modifier = Modifier.fillMaxSize(), @@ -702,8 +755,7 @@ fun ChatsContent( archivedTitles = archivedTitles, archivedUnmuted = archivedPreview.unmuted, archivedMuted = if (showMutedCounter) archivedPreview.muted else 0, - showArchiveRow = folderId == null && !searchOpen && - state.query.isBlank() && archivedCount > 0, + showArchiveRow = folderId == null && !searchOpen && state.query.isBlank() && archivedCount > 0, loading = state.loading && activePage, error = state.error, hasMore = state.hasMore && activePage, @@ -886,10 +938,10 @@ internal fun FolderChipMenu( onEditFolders: () -> Unit, ) { val unread = remember(folderId, chats, folders) { - if (folderId == null) emptyList() else { - visibleChats(chats, folders, folderId) - .filter { it.unreadCount > 0 } - .map { it.id } + if (folderId == null) { + emptyList() + } else { + visibleChats(chats, folders, folderId).filter { it.unreadCount > 0 }.map { it.id } } } AppMenuSurface { @@ -920,8 +972,8 @@ private fun rememberSelfAvatar( mediaRepository?.cacheGeneration( peerAvatarCacheKey( it, - self.avatarCacheKey - ) + self.avatarCacheKey, + ), ) } }, @@ -929,18 +981,15 @@ private fun rememberSelfAvatar( identity = self?.id?.value to self?.avatarCacheKey, resolve = { self?.avatarCacheKey?.let { mediaRepository?.cachedFile(it) } - ?: self?.id?.let { mediaRepository?.cachedAvatar(it) } }, ensure = { val repo = mediaRepository ?: return@rememberEnsuredFile null val peer = self?.id ?: return@rememberEnsuredFile null - when ( - val result = repo.ensureLocalAvatar( - peer, - peerAvatarCacheKey(peer, self.avatarCacheKey), - MediaPriority.THUMB, - ) - ) { + when (val result = repo.ensureLocalAvatar( + peer, + peerAvatarCacheKey(peer, self.avatarCacheKey), + MediaPriority.THUMB, + )) { is Outcome.Ok -> result.value is Outcome.Err -> null } @@ -955,8 +1004,14 @@ private fun rememberDebouncedSync( var shown by remember { mutableStateOf(status) } LaunchedEffect(status, immediate) { when { - status == AppSyncStatus.Hidden -> shown = AppSyncStatus.Hidden - immediate -> shown = status + status == AppSyncStatus.Hidden -> { + shown = AppSyncStatus.Hidden + } + + immediate -> { + shown = status + } + else -> { delay(220) shown = status @@ -984,7 +1039,7 @@ private val FolderScrollSaver = listSaver, Int>( listOf( id, scroll.index, - scroll.offset + scroll.offset, ) } }, @@ -1111,9 +1166,8 @@ internal fun FolderEmptyState( } @Composable -private fun listMotionEnabled(): Boolean = - android.provider.Settings.Global.getFloat( - LocalContext.current.contentResolver, - android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, - 1f, - ) > 0f +private fun listMotionEnabled(): Boolean = android.provider.Settings.Global.getFloat( + LocalContext.current.contentResolver, + android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, +) > 0f diff --git a/feature/chats/src/main/res/values-es/strings.xml b/feature/chats/src/main/res/values-es/strings.xml index 637116ea9..879835e0a 100644 --- a/feature/chats/src/main/res/values-es/strings.xml +++ b/feature/chats/src/main/res/values-es/strings.xml @@ -5,6 +5,11 @@ No hay chats a los que enviar Solo puedes enviar mensajes en los chats donde esto esté permitido Ajustes + Cuentas + Añadir cuenta + Se requiere iniciar sesión + Cuenta no disponible + Cuenta %1$d Yo Mi perfil Actualizar diff --git a/feature/chats/src/main/res/values-hy/strings.xml b/feature/chats/src/main/res/values-hy/strings.xml index 439afdc77..909378358 100644 --- a/feature/chats/src/main/res/values-hy/strings.xml +++ b/feature/chats/src/main/res/values-hy/strings.xml @@ -5,6 +5,11 @@ Ուղարկելու զրույցներ չկան Հաղորդագրություն կարող եք ուղարկել միայն այն զրույցներ, որտեղ դա թույլատրված է Կարգավորումներ + Հաշիվներ + Ավելացնել հաշիվ + Պահանջվում է մուտք + Հաշիվն անհասանելի է + Հաշիվ %1$d Ես Իմ պրոֆիլը Թարմացնել diff --git a/feature/chats/src/main/res/values-ja/strings.xml b/feature/chats/src/main/res/values-ja/strings.xml index 75e401a0b..3854e5fba 100644 --- a/feature/chats/src/main/res/values-ja/strings.xml +++ b/feature/chats/src/main/res/values-ja/strings.xml @@ -5,6 +5,11 @@ 送信できるチャットがありません メッセージを送れるチャットにだけ送信できます 設定 + アカウント + アカウントを追加 + ログインが必要です + アカウントを利用できません + アカウント %1$d 自分 マイプロフィール 更新 diff --git a/feature/chats/src/main/res/values-pt-rBR/strings.xml b/feature/chats/src/main/res/values-pt-rBR/strings.xml index f9b582c42..f17b03189 100644 --- a/feature/chats/src/main/res/values-pt-rBR/strings.xml +++ b/feature/chats/src/main/res/values-pt-rBR/strings.xml @@ -5,6 +5,11 @@ Nenhuma conversa para enviar Você só pode enviar mensagens em conversas onde isso é permitido Configurações + Contas + Adicionar conta + É necessário iniciar sessão + Conta indisponível + Conta %1$d Eu Meu perfil Atualizar diff --git a/feature/chats/src/main/res/values-ru/strings.xml b/feature/chats/src/main/res/values-ru/strings.xml index 299d8891d..75d5335af 100644 --- a/feature/chats/src/main/res/values-ru/strings.xml +++ b/feature/chats/src/main/res/values-ru/strings.xml @@ -5,6 +5,11 @@ Нет чатов для отправки Отправлять сообщения можно только в чаты, где это разрешено Настройки + Аккаунты + Добавить аккаунт + Требуется вход + Аккаунт недоступен + Аккаунт %1$d Я Мой профиль Обновить diff --git a/feature/chats/src/main/res/values-sk/strings.xml b/feature/chats/src/main/res/values-sk/strings.xml index 3f7aaabdc..458b888d2 100644 --- a/feature/chats/src/main/res/values-sk/strings.xml +++ b/feature/chats/src/main/res/values-sk/strings.xml @@ -5,6 +5,11 @@ Žiadne chaty na odoslanie Správy môžete posielať len do chatov, kde je to povolené Nastavenia + Účty + Pridať účet + Vyžaduje sa prihlásenie + Účet nie je dostupný + Účet %1$d Ja Môj profil Obnoviť diff --git a/feature/chats/src/main/res/values-tr/strings.xml b/feature/chats/src/main/res/values-tr/strings.xml index 7de7abdbf..eef64123d 100644 --- a/feature/chats/src/main/res/values-tr/strings.xml +++ b/feature/chats/src/main/res/values-tr/strings.xml @@ -5,6 +5,11 @@ Gönderilebilecek sohbet yok Yalnızca buna izin verilen sohbetlere ileti gönderebilirsin Ayarlar + Hesaplar + Hesap ekle + Giriş gerekli + Hesap kullanılamıyor + Hesap %1$d Ben Profilim Yenile diff --git a/feature/chats/src/main/res/values-uk/strings.xml b/feature/chats/src/main/res/values-uk/strings.xml index 1a5427d31..d32e7d9d2 100644 --- a/feature/chats/src/main/res/values-uk/strings.xml +++ b/feature/chats/src/main/res/values-uk/strings.xml @@ -5,6 +5,11 @@ Немає чатів для надсилання Надсилати повідомлення можна лише в чати, де це дозволено Налаштування + Облікові записи + Додати обліковий запис + Потрібен вхід + Обліковий запис недоступний + Обліковий запис %1$d Я Мій профіль Оновити diff --git a/feature/chats/src/main/res/values-zh/strings.xml b/feature/chats/src/main/res/values-zh/strings.xml index c1252cd6d..431fb4a27 100644 --- a/feature/chats/src/main/res/values-zh/strings.xml +++ b/feature/chats/src/main/res/values-zh/strings.xml @@ -5,6 +5,11 @@ 没有可发送的聊天 只能发送到允许你发消息的聊天 设置 + 账号 + 添加账号 + 需要登录 + 账号不可用 + 账号 %1$d 我 我的资料 刷新 diff --git a/feature/chats/src/main/res/values/strings.xml b/feature/chats/src/main/res/values/strings.xml index 9ebc73697..6f7fc27a0 100644 --- a/feature/chats/src/main/res/values/strings.xml +++ b/feature/chats/src/main/res/values/strings.xml @@ -5,6 +5,11 @@ No chats you can send to You can send messages only in chats where this is allowed Settings + Accounts + Add account + Sign-in required + Account unavailable + Account %1$d Me My profile Refresh diff --git a/feature/chats/src/test/java/org/monogram/feature/chats/ChatsStoreCacheTest.kt b/feature/chats/src/test/java/org/monogram/feature/chats/ChatsStoreCacheTest.kt index 4809e16a3..bc684e5c9 100644 --- a/feature/chats/src/test/java/org/monogram/feature/chats/ChatsStoreCacheTest.kt +++ b/feature/chats/src/test/java/org/monogram/feature/chats/ChatsStoreCacheTest.kt @@ -55,7 +55,13 @@ class ChatsStoreCacheTest { refreshMergeHook: (suspend () -> Unit)? = null, readStateHook: (suspend () -> Unit)? = null, ) = ChatsStoreFactory( - storeFactory, client, warmup, sessionStore, notifications, refreshMergeHook, readStateHook, + storeFactory, + client, + warmup, + sessionStore, + notifications, + refreshMergeHook, + readStateHook, ioContext = Dispatchers.Main.immediate, ) @@ -70,318 +76,432 @@ class ChatsStoreCacheTest { } @Test - fun protocolCatchupKeepsUpdatingAfterDialogsRefreshCompletes() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val release = CompletableDeferred() - val cached = Chat(PeerId(1), "Synthetic", lastMessageDate = 5, lastMessageId = 1) - val warmup = object : OfflineWarmup() { - override suspend fun chatsWindow(limit: Int, archiveLimit: Int) = listOf(cached) - override suspend fun mainListCount() = 1 - } - val client = StubClient(chats = Outcome.Ok(listOf(cached))) - client.chatsGate = release - val store = - ChatsStoreFactory(DefaultStoreFactory(), client, warmup, sessionStore = null).create() - try { - runCurrent() - client.events.emit(MtprotoUpdate.SyncState(true)) - release.complete(Unit) - advanceUntilIdle() - assertFalse(store.state.loading) - assertEquals(cached.id, store.state.chats.single().id) - assertTrue( - "Unfinished protocol recovery must keep Updating visible", - store.state.syncing - ) - client.events.emit(MtprotoUpdate.SyncState(false)) - runCurrent() - assertFalse(store.state.syncing) - } finally { - release.complete(Unit) - store.dispose() - } - } + fun protocolCatchupKeepsUpdatingAfterDialogsRefreshCompletes() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val release = CompletableDeferred() + val cached = Chat(PeerId(1), "Synthetic", lastMessageDate = 5, lastMessageId = 1) + val warmup = + object : OfflineWarmup() { + override suspend fun chatsWindow( + limit: Int, + archiveLimit: Int, + ) = listOf(cached) - @Test - fun reconnectCatchupUpdatesAlreadyLoadedChats() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val client = StubClient(chats = Outcome.Ok(emptyList())) - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup = null, - sessionStore = null - ).create() - try { - advanceUntilIdle() - assertFalse(store.state.syncing) - client.events.emit(MtprotoUpdate.SyncState(true)) - runCurrent() - assertTrue(store.state.syncing) - client.events.emit(MtprotoUpdate.SyncState(false)) - runCurrent() - assertFalse(store.state.syncing) - } finally { - store.dispose() + override suspend fun mainListCount() = 1 + } + val client = StubClient(chats = Outcome.Ok(listOf(cached))) + client.chatsGate = release + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null + ).create() + try { + runCurrent() + client.events.emit(MtprotoUpdate.SyncState(true)) + release.complete(Unit) + advanceUntilIdle() + assertFalse(store.state.loading) + assertEquals( + cached.id, + store.state.chats + .single() + .id, + ) + assertTrue( + "Unfinished protocol recovery must keep Updating visible", + store.state.syncing, + ) + client.events.emit(MtprotoUpdate.SyncState(false)) + runCurrent() + assertFalse(store.state.syncing) + } finally { + release.complete(Unit) + store.dispose() + } } - } @Test - fun protocolCompletionDoesNotClearAnOutstandingDialogsRefresh() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val release = CompletableDeferred() - val cached = Chat(PeerId(1), "Synthetic", lastMessageDate = 5, lastMessageId = 1) - val warmup = object : OfflineWarmup() { - override suspend fun chatsWindow(limit: Int, archiveLimit: Int) = listOf(cached) - override suspend fun mainListCount() = 1 - } - val client = StubClient(chats = Outcome.Ok(listOf(cached))) - client.chatsGate = release - val store = - ChatsStoreFactory(DefaultStoreFactory(), client, warmup, sessionStore = null).create() - try { - runCurrent() - client.events.emit(MtprotoUpdate.SyncState(true)) - client.events.emit(MtprotoUpdate.SyncState(false)) - runCurrent() - assertTrue(store.state.syncing) - release.complete(Unit) - advanceUntilIdle() - assertFalse(store.state.syncing) - } finally { - release.complete(Unit) - store.dispose() + fun reconnectCatchupUpdatesAlreadyLoadedChats() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val client = StubClient(chats = Outcome.Ok(emptyList())) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + assertFalse(store.state.syncing) + client.events.emit(MtprotoUpdate.SyncState(true)) + runCurrent() + assertTrue(store.state.syncing) + client.events.emit(MtprotoUpdate.SyncState(false)) + runCurrent() + assertFalse(store.state.syncing) + } finally { + store.dispose() + } } - } @Test - fun cachePaintsBeforeDialogsRequest() = runTest { - val release = CompletableDeferred() - val cached = Chat(PeerId(1), "Ada", lastMessageDate = 5, lastMessageId = 1) - val warmup = object : OfflineWarmup() { - override suspend fun chatsWindow(limit: Int, archiveLimit: Int) = listOf(cached) - override suspend fun mainListCount() = 1 - } - val client = StubClient(chats = Outcome.Ok(emptyList())) - client.chatsGate = release - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup, sessionStore = null, - ).create() - try { - runCurrent() - assertEquals("Ada", store.state.chats.single().title) - assertTrue(store.state.fromCache) - assertFalse(release.isCompleted) - } finally { - release.complete(Unit) - store.dispose() - } - } + fun protocolCompletionDoesNotClearAnOutstandingDialogsRefresh() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val release = CompletableDeferred() + val cached = Chat(PeerId(1), "Synthetic", lastMessageDate = 5, lastMessageId = 1) + val warmup = + object : OfflineWarmup() { + override suspend fun chatsWindow( + limit: Int, + archiveLimit: Int, + ) = listOf(cached) - @Test - fun backgroundCatalogKeepsTheSmallWindowAndUpsertsLaterPages() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val first = List(DIALOGS_NETWORK_PAGE) { index -> - val id = index + 1 - Chat( - PeerId(id.toLong()), - "C$id", - lastMessageDate = (1_000 - index).toLong(), - lastMessageId = id, - ) - } - val later = Chat(PeerId(900), "Later", lastMessageDate = 10, lastMessageId = 900) - val upserted = mutableListOf() - val warmup = object : OfflineWarmup() { - override suspend fun upsertChats(chats: List) { - upserted += chats + override suspend fun mainListCount() = 1 + } + val client = StubClient(chats = Outcome.Ok(listOf(cached))) + client.chatsGate = release + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null + ).create() + try { + runCurrent() + client.events.emit(MtprotoUpdate.SyncState(true)) + client.events.emit(MtprotoUpdate.SyncState(false)) + runCurrent() + assertTrue(store.state.syncing) + release.complete(Unit) + advanceUntilIdle() + assertFalse(store.state.syncing) + } finally { + release.complete(Unit) + store.dispose() } } - val client = StubClient(chats = Outcome.Ok(first)) - client.mainPages.add(listOf(later)) - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup, sessionStore = null, - ).create() - try { - advanceUntilIdle() - assertFalse(store.state.chats.any { it.id == later.id }) - assertTrue( - "offsets=${client.mainOffsets} upserted=${upserted.map { it.id.value }}", - upserted.any { it.id == later.id }, - ) - assertTrue(client.mainOffsets.none { it.first == 0 && it.second == 0 }) - assertTrue(client.historyCalls == 0) - } finally { - store.dispose() - } - } @Test - fun restartResumesCatalogFromTheOldestSavedRow() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val top = Chat(PeerId(5), "Top", lastMessageDate = 50, lastMessageId = 5) - val oldest = Chat(PeerId(1), "Old", lastMessageDate = 9, lastMessageId = 1) - val warmup = object : OfflineWarmup() { - override suspend fun oldestListed(archived: Boolean): Chat? = - if (archived) null else oldest - } - val client = StubClient(chats = Outcome.Ok(listOf(top))) - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup, sessionStore = null, - ).create() - try { - advanceUntilIdle() - assertEquals(listOf(Triple(9, 1, 1L)), client.mainOffsets) - } finally { - store.dispose() + fun cachePaintsBeforeDialogsRequest() = + runTest { + val release = CompletableDeferred() + val cached = Chat(PeerId(1), "Ada", lastMessageDate = 5, lastMessageId = 1) + val warmup = + object : OfflineWarmup() { + override suspend fun chatsWindow( + limit: Int, + archiveLimit: Int, + ) = listOf(cached) + + override suspend fun mainListCount() = 1 + } + val client = StubClient(chats = Outcome.Ok(emptyList())) + client.chatsGate = release + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + ).create() + try { + runCurrent() + assertEquals( + "Ada", + store.state.chats + .single() + .title, + ) + assertTrue(store.state.fromCache) + assertFalse(release.isCompleted) + } finally { + release.complete(Unit) + store.dispose() + } } - } @Test - fun persistedWarmupStartsTheArchiveCatalogAtOffsetZero() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val warmup = object : OfflineWarmup() { - override val usesIoDispatcher = true - override suspend fun oldestListed(archived: Boolean): Chat? = null - override suspend fun upsertChats(chats: List) = Unit - } - val client = StubClient( - chats = Outcome.Ok( - listOf( + fun backgroundCatalogKeepsTheSmallWindowAndUpsertsLaterPages() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val first = + List(DIALOGS_NETWORK_PAGE) { index -> + val id = index + 1 Chat( - PeerId(1), - "Top", - lastMessageDate = 1, - lastMessageId = 1 + PeerId(id.toLong()), + "C$id", + lastMessageDate = (1_000 - index).toLong(), + lastMessageId = id, ) + } + val later = Chat(PeerId(900), "Later", lastMessageDate = 10, lastMessageId = 900) + val upserted = mutableListOf() + val warmup = + object : OfflineWarmup() { + override suspend fun upsertChats(chats: List) { + upserted += chats + } + } + val client = StubClient(chats = Outcome.Ok(first)) + client.mainPages.add(listOf(later)) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + assertFalse(store.state.chats.any { it.id == later.id }) + assertTrue( + "offsets=${client.mainOffsets} upserted=${upserted.map { it.id.value }}", + upserted.any { it.id == later.id }, ) - ), - ) - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup, sessionStore = null, - ).create() - try { - advanceUntilIdle() - assertEquals(listOf(ARCHIVE_FOLDER_WIRE_ID), client.folderCalls) - } finally { - store.dispose() + assertTrue(client.mainOffsets.none { it.first == 0 && it.second == 0 }) + assertTrue(client.historyCalls == 0) + } finally { + store.dispose() + } } - } @Test - fun catalogFloodWaitRetriesTheSamePage() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val first = List(DIALOGS_NETWORK_PAGE) { index -> - val id = index + 1 - Chat( - PeerId(id.toLong()), - "C$id", - lastMessageDate = (1_000 - index).toLong(), - lastMessageId = id - ) - } - val later = Chat(PeerId(900), "Later", lastMessageDate = 10, lastMessageId = 900) - val upserted = mutableListOf() - val warmup = object : OfflineWarmup() { - override suspend fun upsertChats(chats: List) { - upserted += chats + fun restartResumesCatalogFromTheOldestSavedRow() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val top = Chat(PeerId(5), "Top", lastMessageDate = 50, lastMessageId = 5) + val oldest = Chat(PeerId(1), "Old", lastMessageDate = 9, lastMessageId = 1) + val warmup = + object : OfflineWarmup() { + override suspend fun oldestListed(archived: Boolean): Chat? = + if (archived) null else oldest + } + val client = StubClient(chats = Outcome.Ok(listOf(top))) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + assertEquals(listOf(Triple(9, 1, 1L)), client.mainOffsets) + } finally { + store.dispose() } } - val client = StubClient(chats = Outcome.Ok(first)).apply { - mainFlood = "FLOOD_WAIT_1" - mainPages.add(listOf(later)) + + @Test + fun persistedWarmupStartsTheArchiveCatalogAtOffsetZero() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val warmup = + object : OfflineWarmup() { + override val usesIoDispatcher = true + + override suspend fun oldestListed(archived: Boolean): Chat? = null + + override suspend fun upsertChats(chats: List) = Unit + } + val client = + StubClient( + chats = + Outcome.Ok( + listOf( + Chat( + PeerId(1), + "Top", + lastMessageDate = 1, + lastMessageId = 1, + ), + ), + ), + ) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + assertEquals(listOf(ARCHIVE_FOLDER_WIRE_ID), client.folderCalls) + } finally { + store.dispose() + } } - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup, sessionStore = null, - ).create() - try { - advanceTimeBy(1_000) - advanceUntilIdle() - assertTrue(upserted.any { it.id == later.id }) - assertEquals(2, client.mainOffsets.size) - } finally { - store.dispose() + + @Test + fun catalogFloodWaitRetriesTheSamePage() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val first = + List(DIALOGS_NETWORK_PAGE) { index -> + val id = index + 1 + Chat( + PeerId(id.toLong()), + "C$id", + lastMessageDate = (1_000 - index).toLong(), + lastMessageId = id, + ) + } + val later = Chat(PeerId(900), "Later", lastMessageDate = 10, lastMessageId = 900) + val upserted = mutableListOf() + val warmup = + object : OfflineWarmup() { + override suspend fun upsertChats(chats: List) { + upserted += chats + } + } + val client = + StubClient(chats = Outcome.Ok(first)).apply { + mainFlood = "FLOOD_WAIT_1" + mainPages.add(listOf(later)) + } + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + ).create() + try { + advanceTimeBy(1_000) + advanceUntilIdle() + assertTrue(upserted.any { it.id == later.id }) + assertEquals(2, client.mainOffsets.size) + } finally { + store.dispose() + } } - } @Test - fun scrollServesCachedRowsBeforeTheNetwork() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val first = Chat(PeerId(5), "Newer", lastMessageDate = 50, lastMessageId = 1) - val older = Chat(PeerId(4), "Older", lastMessageDate = 40, lastMessageId = 1) - val warmup = object : OfflineWarmup() { - override suspend fun chatsWindow(limit: Int, archiveLimit: Int) = listOf(first) - override suspend fun mainListCount() = 2 - override suspend fun chatsAfter(cursor: ChatCacheCursor, limit: Int) = listOf(older) - } - val client = StubClient(chats = Outcome.Ok(listOf(first))) - client.mainPages.add( - listOf( - Chat( - PeerId(3), - "Network", - lastMessageDate = 3, - lastMessageId = 3 - ) + fun scrollServesCachedRowsBeforeTheNetwork() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val first = Chat(PeerId(5), "Newer", lastMessageDate = 50, lastMessageId = 1) + val older = Chat(PeerId(4), "Older", lastMessageDate = 40, lastMessageId = 1) + val warmup = + object : OfflineWarmup() { + override suspend fun chatsWindow( + limit: Int, + archiveLimit: Int, + ) = listOf(first) + + override suspend fun mainListCount() = 2 + + override suspend fun chatsAfter( + cursor: ChatCacheCursor, + limit: Int, + ) = listOf(older) + } + val client = StubClient(chats = Outcome.Ok(listOf(first))) + client.mainPages.add( + listOf( + Chat( + PeerId(3), + "Network", + lastMessageDate = 3, + lastMessageId = 3, + ), + ), ) - ) - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup, sessionStore = null, - ).create() - try { - advanceUntilIdle() - val offsetsBeforeScroll = client.mainOffsets.size - store.accept(ChatsStore.Intent.LoadMore) - advanceUntilIdle() - assertTrue(store.state.chats.any { it.id == older.id }) - assertEquals(offsetsBeforeScroll, client.mainOffsets.size) - } finally { - store.dispose() + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + val offsetsBeforeScroll = client.mainOffsets.size + store.accept(ChatsStore.Intent.LoadMore) + advanceUntilIdle() + assertTrue(store.state.chats.any { it.id == older.id }) + assertEquals(offsetsBeforeScroll, client.mainOffsets.size) + } finally { + store.dispose() + } } - } @Test - fun loadMoreUsesKeysetCursor() = runTest { - var seen: ChatCacheCursor? = null - val first = Chat(PeerId(5), "Newer", lastMessageDate = 50, lastMessageId = 1) - val warmup = object : OfflineWarmup(db = null) { - override suspend fun chatsWindow(limit: Int, archiveLimit: Int) = listOf(first) - override suspend fun mainListCount() = 2 - override suspend fun chatsAfter(cursor: ChatCacheCursor, limit: Int): List { - seen = cursor - return listOf(Chat(PeerId(4), "Older", lastMessageDate = 40, lastMessageId = 1)) + fun loadMoreUsesKeysetCursor() = + runTest { + var seen: ChatCacheCursor? = null + val first = Chat(PeerId(5), "Newer", lastMessageDate = 50, lastMessageId = 1) + val warmup = + object : OfflineWarmup(db = null) { + override suspend fun chatsWindow( + limit: Int, + archiveLimit: Int, + ) = listOf(first) + + override suspend fun mainListCount() = 2 + + override suspend fun chatsAfter( + cursor: ChatCacheCursor, + limit: Int, + ): List { + seen = cursor + return listOf( + Chat( + PeerId(4), + "Older", + lastMessageDate = 40, + lastMessageId = 1 + ) + ) + } + } + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + StubClient(chats = Outcome.Ok(listOf(first))), + warmup, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + assertEquals(5L, seen?.id) + assertEquals(50L, seen?.lastMessageDate) + assertFalse(seen?.archived ?: true) + } finally { + store.dispose() } } - val store = ChatsStoreFactory( - DefaultStoreFactory(), - StubClient(chats = Outcome.Ok(listOf(first))), - warmup, - sessionStore = null, - ).create() - try { - advanceUntilIdle() - assertEquals(5L, seen?.id) - assertEquals(50L, seen?.lastMessageDate) - assertFalse(seen?.archived ?: true) - } finally { - store.dispose() - } - } @Test fun cachePaintsBeforeFailedNetwork() { val cached = Chat(PeerId(1), "Ada") - val warmup = object : OfflineWarmup() { - override suspend fun chats() = listOf(cached) - } - val store = ChatsStoreFactory( - DefaultStoreFactory(), - StubClient(chats = Outcome.Err("offline")), - warmup, - sessionStore = null, - ).create() + val warmup = + object : OfflineWarmup() { + override suspend fun chats() = listOf(cached) + } + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + StubClient(chats = Outcome.Err("offline")), + warmup, + sessionStore = null, + ).create() try { - assertEquals("Ada", store.state.chats.single().title) + assertEquals( + "Ada", + store.state.chats + .single() + .title, + ) assertTrue(store.state.fromCache) assertNull(store.state.error) assertTrue(!store.state.loading) @@ -391,643 +511,882 @@ class ChatsStoreCacheTest { } @Test - fun rejectedCustomFolderPagesArchiveStreamForArchivedMembers() = runTest { - val archived = Chat( - PeerId(99), - "Archived late member", - archived = true, - unreadCount = 2, - lastMessageDate = 99, - lastMessageId = 1, - ) - val archivePageOne = (100L..139L).map { id -> - Chat(PeerId(id), "Archive $id", archived = true, lastMessageDate = id, lastMessageId = 1) - } - val client = StubClient( - chats = Outcome.Ok( - (1L..40L).map { id -> + fun rejectedCustomFolderPagesArchiveStreamForArchivedMembers() = + runTest { + val archived = + Chat( + PeerId(99), + "Archived late member", + archived = true, + unreadCount = 2, + lastMessageDate = 99, + lastMessageId = 1, + ) + val archivePageOne = + (100L..139L).map { id -> Chat( PeerId(id), - "Initial archive $id", + "Archive $id", archived = true, lastMessageDate = id, - lastMessageId = 1, + lastMessageId = 1 ) - }, - ), - ).apply { - archivePages.addLast(archivePageOne) - archivePages.addLast(listOf(archived)) - } - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - ).create() - try { - store.accept(ChatsStore.Intent.FolderSelected(7)) - advanceUntilIdle() - val folder = Folder( - id = 7, - title = "Work", - chatIds = listOf(archived.id), - excludeArchived = true, - ) - val visible = visibleChats(store.state.chats, listOf(folder), 7) - assertTrue("folder calls=${client.folderCalls}", client.folderCalls.contains(7)) - assertTrue( - "archive calls=${client.folderCalls}", - client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID } >= 2, - ) - assertEquals(listOf(99L), visible.map { it.id.value }) - assertEquals(FolderUnreadBadge(unmuted = 1, muted = 0), folderUnreadBadge(visible)) + } + val client = + StubClient( + chats = + Outcome.Ok( + (1L..40L).map { id -> + Chat( + PeerId(id), + "Initial archive $id", + archived = true, + lastMessageDate = id, + lastMessageId = 1, + ) + }, + ), + ).apply { + archivePages.addLast(archivePageOne) + archivePages.addLast(listOf(archived)) + } + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + store.accept(ChatsStore.Intent.FolderSelected(7)) + advanceUntilIdle() + val folder = + Folder( + id = 7, + title = "Work", + chatIds = listOf(archived.id), + excludeArchived = true, + ) + val visible = visibleChats(store.state.chats, listOf(folder), 7) + assertTrue("folder calls=${client.folderCalls}", client.folderCalls.contains(7)) + assertTrue( + "archive calls=${client.folderCalls}", + client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID } >= 2, + ) + assertEquals(listOf(99L), visible.map { it.id.value }) + assertEquals(FolderUnreadBadge(unmuted = 1, muted = 0), folderUnreadBadge(visible)) - // Both fallback streams are now exhausted and must terminate cleanly. - store.accept(ChatsStore.Intent.LoadMore) - advanceUntilIdle() - assertFalse(store.state.hasMore) - } finally { - store.dispose() + // Both fallback streams are now exhausted and must terminate cleanly. + store.accept(ChatsStore.Intent.LoadMore) + advanceUntilIdle() + assertFalse(store.state.hasMore) + } finally { + store.dispose() + } } - } @Test - fun exhaustedMainStreamDoesNotStarveArchiveFallbackOrLeakOnFolderSwitch() = runTest { - val archived = Chat( - PeerId(199), - "Archived second page", - archived = true, - lastMessageDate = 199, - lastMessageId = 1, - ) - val client = StubClient( - chats = Outcome.Ok(listOf(Chat(PeerId(1), "Main", lastMessageDate = 1, lastMessageId = 1))), - ).apply { - archivePages.addLast((100L..139L).map { id -> - Chat(PeerId(id), "Archive $id", archived = true, lastMessageDate = id, lastMessageId = 1) - }) - archivePages.addLast(listOf(archived)) - } - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - ).create() - try { - store.accept(ChatsStore.Intent.FolderSelected(7)) - advanceUntilIdle() - assertTrue(client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID } >= 2) - assertTrue( - visibleChats( - store.state.chats, - listOf(Folder(7, "Work", chatIds = listOf(archived.id), excludeArchived = true)), - 7, - ).any { it.id == archived.id }, - ) - assertFalse(store.state.hasMore) - store.accept(ChatsStore.Intent.FolderSelected(null)) - advanceUntilIdle() - assertFalse(store.state.hasMore) - } finally { - store.dispose() + fun exhaustedMainStreamDoesNotStarveArchiveFallbackOrLeakOnFolderSwitch() = + runTest { + val archived = + Chat( + PeerId(199), + "Archived second page", + archived = true, + lastMessageDate = 199, + lastMessageId = 1, + ) + val client = + StubClient( + chats = Outcome.Ok( + listOf( + Chat( + PeerId(1), + "Main", + lastMessageDate = 1, + lastMessageId = 1 + ) + ) + ), + ).apply { + archivePages.addLast( + (100L..139L).map { id -> + Chat( + PeerId(id), + "Archive $id", + archived = true, + lastMessageDate = id, + lastMessageId = 1 + ) + }, + ) + archivePages.addLast(listOf(archived)) + } + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + store.accept(ChatsStore.Intent.FolderSelected(7)) + advanceUntilIdle() + assertTrue(client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID } >= 2) + assertTrue( + visibleChats( + store.state.chats, + listOf( + Folder( + 7, + "Work", + chatIds = listOf(archived.id), + excludeArchived = true + ) + ), + 7, + ).any { it.id == archived.id }, + ) + assertFalse(store.state.hasMore) + store.accept(ChatsStore.Intent.FolderSelected(null)) + advanceUntilIdle() + assertFalse(store.state.hasMore) + } finally { + store.dispose() + } } - } @Test - fun switchingFoldersWhilePagingDefersAndStartsTheNewFolderLoad() = runTest { - val client = StubClient( - chats = Outcome.Ok(listOf(Chat(PeerId(1), "Main", lastMessageDate = 1, lastMessageId = 1))), - ).apply { - blockedFolderId = 7 - } - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - ).create() - try { - advanceUntilIdle() - store.accept(ChatsStore.Intent.FolderSelected(7)) - runCurrent() - assertTrue(client.blockedFolderStarted.isCompleted) + fun switchingFoldersWhilePagingDefersAndStartsTheNewFolderLoad() = + runTest { + val client = + StubClient( + chats = Outcome.Ok( + listOf( + Chat( + PeerId(1), + "Main", + lastMessageDate = 1, + lastMessageId = 1 + ) + ) + ), + ).apply { + blockedFolderId = 7 + } + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + store.accept(ChatsStore.Intent.FolderSelected(7)) + runCurrent() + assertTrue(client.blockedFolderStarted.isCompleted) - store.accept(ChatsStore.Intent.FolderSelected(8)) - runCurrent() - assertEquals(listOf(7), client.folderCalls) + store.accept(ChatsStore.Intent.FolderSelected(8)) + runCurrent() + assertEquals(listOf(7), client.folderCalls) - client.releaseBlockedFolder.complete(Unit) - advanceUntilIdle() + client.releaseBlockedFolder.complete(Unit) + advanceUntilIdle() - assertTrue(client.folderCalls.contains(8)) - assertFalse(store.state.loadingMore) - } finally { - store.dispose() + assertTrue(client.folderCalls.contains(8)) + assertFalse(store.state.loadingMore) + } finally { + store.dispose() + } } - } @Test - fun switchingFoldersDuringCachedTailPagingStartsArchiveRequest() = runTest { - val client = StubClient( - chats = Outcome.Ok((1L..80L).map { id -> - Chat(PeerId(id), "Main $id", lastMessageDate = id, lastMessageId = 1) - }), - ) - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - ).create() - try { - repeat(20) { + fun switchingFoldersDuringCachedTailPagingStartsArchiveRequest() = + runTest { + val client = + StubClient( + chats = + Outcome.Ok( + (1L..80L).map { id -> + Chat( + PeerId(id), + "Main $id", + lastMessageDate = id, + lastMessageId = 1 + ) + }, + ), + ) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + repeat(20) { + runCurrent() + if (store.state.chats.isNotEmpty()) return@repeat + } + if (!store.state.loadingMore) store.accept(ChatsStore.Intent.LoadMore) + runCurrent() + // Select while the cache owner is either draining or about to release its gate. + store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) runCurrent() - if (store.state.chats.isNotEmpty()) return@repeat + advanceUntilIdle() + assertTrue(client.folderCalls.contains(ARCHIVE_FOLDER_WIRE_ID)) + } finally { + store.dispose() } - if (!store.state.loadingMore) store.accept(ChatsStore.Intent.LoadMore) - runCurrent() - // Select while the cache owner is either draining or about to release its gate. - store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) - runCurrent() - advanceUntilIdle() - assertTrue(client.folderCalls.contains(ARCHIVE_FOLDER_WIRE_ID)) - } finally { - store.dispose() } - } @Test - fun switchingFoldersDuringRoomPagingStartsArchiveRequest() = runTest { - val roomStarted = CompletableDeferred() - val releaseRoom = CompletableDeferred() - val warmup = object : OfflineWarmup(db = null) { - override suspend fun chatsWindow(limit: Int, archiveLimit: Int): List = emptyList() - override suspend fun mainListCount(): Int = 2 - override suspend fun chatsAfter(cursor: ChatCacheCursor, limit: Int): List { - roomStarted.complete(Unit) - releaseRoom.await() - return listOf(Chat(PeerId(2), "Room", lastMessageDate = 2, lastMessageId = 1)) + fun switchingFoldersDuringRoomPagingStartsArchiveRequest() = + runTest { + val roomStarted = CompletableDeferred() + val releaseRoom = CompletableDeferred() + val warmup = + object : OfflineWarmup(db = null) { + override suspend fun chatsWindow( + limit: Int, + archiveLimit: Int, + ): List = emptyList() + + override suspend fun mainListCount(): Int = 2 + + override suspend fun chatsAfter( + cursor: ChatCacheCursor, + limit: Int, + ): List { + roomStarted.complete(Unit) + releaseRoom.await() + return listOf( + Chat( + PeerId(2), + "Room", + lastMessageDate = 2, + lastMessageId = 1 + ) + ) + } + } + val client = + StubClient( + chats = Outcome.Ok( + listOf( + Chat( + PeerId(1), + "Main", + lastMessageDate = 1, + lastMessageId = 1 + ) + ) + ), + ) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + store.accept(ChatsStore.Intent.LoadMore) + runCurrent() + assertTrue(roomStarted.isCompleted) + store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) + runCurrent() + releaseRoom.complete(Unit) + advanceUntilIdle() + assertTrue(client.folderCalls.contains(ARCHIVE_FOLDER_WIRE_ID)) + } finally { + store.dispose() } } - val client = StubClient( - chats = Outcome.Ok(listOf(Chat(PeerId(1), "Main", lastMessageDate = 1, lastMessageId = 1))), - ) - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup, sessionStore = null, - ).create() - try { - advanceUntilIdle() - store.accept(ChatsStore.Intent.LoadMore) - runCurrent() - assertTrue(roomStarted.isCompleted) - store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) - runCurrent() - releaseRoom.complete(Unit) - advanceUntilIdle() - assertTrue(client.folderCalls.contains(ARCHIVE_FOLDER_WIRE_ID)) - } finally { - store.dispose() - } - } @Test - fun archivePageSurvivesChatsChanged() = runTest { - val archived = archiveChats(20) - val client = StubClient( - chats = Outcome.Ok(listOf(Chat(PeerId(100), "Main", lastMessageDate = 100, lastMessageId = 1))), - ).apply { - archivePages.addLast(archived) - } - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - ).create() - try { - advanceUntilIdle() - store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) - advanceUntilIdle() - assertEquals(20, visibleArchive(store).size) - client.events.emit( - MtprotoUpdate.ChatsChanged(listOf(archived.last().copy(title = "Updated"))), - ) - advanceUntilIdle() - val visible = visibleArchive(store) - assertEquals(20, visible.size) - assertEquals("Updated", visible.first { it.id.value == 20L }.title) - } finally { - store.dispose() + fun archivePageSurvivesChatsChanged() = + runTest { + val archived = archiveChats(20) + val client = + StubClient( + chats = Outcome.Ok( + listOf( + Chat( + PeerId(100), + "Main", + lastMessageDate = 100, + lastMessageId = 1 + ) + ) + ), + ).apply { + archivePages.addLast(archived) + } + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) + advanceUntilIdle() + assertEquals(20, visibleArchive(store).size) + client.events.emit( + MtprotoUpdate.ChatsChanged(listOf(archived.last().copy(title = "Updated"))), + ) + advanceUntilIdle() + val visible = visibleArchive(store) + assertEquals(20, visible.size) + assertEquals("Updated", visible.first { it.id.value == 20L }.title) + } finally { + store.dispose() + } } - } @Test - fun delayedMainRefreshDoesNotCollapseArchiveOrResetPaging() = runTest { - val archived = archiveChats(20) - val client = StubClient( - chats = Outcome.Ok(listOf(Chat(PeerId(100), "Main", lastMessageDate = 100, lastMessageId = 1))), - ).apply { - archivePages.addLast(archived) - chatResponses.addLast( - Outcome.Ok( - (200L..239L).map { id -> - Chat(PeerId(id), "Main $id", lastMessageDate = id, lastMessageId = 1) + fun delayedMainRefreshDoesNotCollapseArchiveOrResetPaging() = + runTest { + val archived = archiveChats(20) + val client = + StubClient( + chats = Outcome.Ok( + listOf( + Chat( + PeerId(100), + "Main", + lastMessageDate = 100, + lastMessageId = 1 + ) + ) + ), + ).apply { + archivePages.addLast(archived) + chatResponses.addLast( + Outcome.Ok( + (200L..239L).map { id -> + Chat( + PeerId(id), + "Main $id", + lastMessageDate = id, + lastMessageId = 1 + ) + }, + ), + ) + } + val mergeEntered = CompletableDeferred() + val releaseMerge = CompletableDeferred() + var hookArmed = false + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + refreshMergeHook = { + if (hookArmed) { + mergeEntered.complete(Unit) + releaseMerge.await() + } }, - ), - ) + ).create() + try { + advanceUntilIdle() + store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) + advanceUntilIdle() + assertEquals(20, visibleArchive(store).size) + assertFalse(store.state.hasMore) + val archiveCalls = client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID } + hookArmed = true + store.accept(ChatsStore.Intent.Refresh) + mergeEntered.await() + releaseMerge.complete(Unit) + advanceUntilIdle() + assertEquals(20, visibleArchive(store).size) + assertFalse(store.state.hasMore) + assertEquals( + archiveCalls, + client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID }, + ) + } finally { + releaseMerge.complete(Unit) + store.dispose() + } } - val mergeEntered = CompletableDeferred() - val releaseMerge = CompletableDeferred() - var hookArmed = false - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup = null, - sessionStore = null, - refreshMergeHook = { - if (hookArmed) { - mergeEntered.complete(Unit) - releaseMerge.await() + + @Test + fun openingArchiveExposesCachedArchivedRowsWithoutWaitingForServer() = + runTest { + val archived = archiveChats(8) + val warmup = + object : OfflineWarmup() { + override suspend fun chats() = + listOf( + Chat( + PeerId(100), + "Main", + lastMessageDate = 100, + lastMessageId = 1 + ) + ) + + archived } - }, - ).create() - try { - advanceUntilIdle() - store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) - advanceUntilIdle() - assertEquals(20, visibleArchive(store).size) - assertFalse(store.state.hasMore) - val archiveCalls = client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID } - hookArmed = true - store.accept(ChatsStore.Intent.Refresh) - mergeEntered.await() - releaseMerge.complete(Unit) - advanceUntilIdle() - assertEquals(20, visibleArchive(store).size) - assertFalse(store.state.hasMore) - assertEquals( - archiveCalls, - client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID }, - ) - } finally { - releaseMerge.complete(Unit) - store.dispose() + val client = + StubClient( + chats = Outcome.Ok( + listOf( + Chat( + PeerId(100), + "Main", + lastMessageDate = 100, + lastMessageId = 1 + ) + ) + ), + ) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + assertEquals(3, visibleArchive(store).size) + store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) + advanceUntilIdle() + assertEquals((8L downTo 1L).toList(), visibleArchive(store).map { it.id.value }) + } finally { + store.dispose() + } } - } @Test - fun openingArchiveExposesCachedArchivedRowsWithoutWaitingForServer() = runTest { - val archived = archiveChats(8) - val warmup = object : OfflineWarmup() { - override suspend fun chats() = - listOf(Chat(PeerId(100), "Main", lastMessageDate = 100, lastMessageId = 1)) + - archived - } - val client = StubClient( - chats = Outcome.Ok(listOf(Chat(PeerId(100), "Main", lastMessageDate = 100, lastMessageId = 1))), - ) - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup, sessionStore = null, - ).create() - try { - advanceUntilIdle() - assertEquals(3, visibleArchive(store).size) - store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) - advanceUntilIdle() - assertEquals((8L downTo 1L).toList(), visibleArchive(store).map { it.id.value }) - } finally { - store.dispose() + fun exhaustedArchiveRepublishDoesNotHideRowsOrRequestAgain() = + runTest { + val archived = archiveChats(20) + val client = + StubClient( + chats = Outcome.Ok( + listOf( + Chat( + PeerId(100), + "Main", + lastMessageDate = 100, + lastMessageId = 1 + ) + ) + ), + ).apply { + archivePages.addLast(archived) + } + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) + advanceUntilIdle() + assertEquals(20, visibleArchive(store).size) + assertFalse(store.state.hasMore) + val archiveCalls = client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID } + client.events.emit( + MtprotoUpdate.ChatsChanged(listOf(archived.last().copy(title = "Updated"))), + ) + advanceUntilIdle() + store.accept(ChatsStore.Intent.LoadMore) + advanceUntilIdle() + assertEquals(20, visibleArchive(store).size) + assertFalse(store.state.hasMore) + assertEquals( + archiveCalls, + client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID }, + ) + } finally { + store.dispose() + } } - } @Test - fun exhaustedArchiveRepublishDoesNotHideRowsOrRequestAgain() = runTest { - val archived = archiveChats(20) - val client = StubClient( - chats = Outcome.Ok(listOf(Chat(PeerId(100), "Main", lastMessageDate = 100, lastMessageId = 1))), - ).apply { - archivePages.addLast(archived) - } - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - ).create() - try { - advanceUntilIdle() - store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) - advanceUntilIdle() - assertEquals(20, visibleArchive(store).size) - assertFalse(store.state.hasMore) - val archiveCalls = client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID } - client.events.emit( - MtprotoUpdate.ChatsChanged(listOf(archived.last().copy(title = "Updated"))), - ) - advanceUntilIdle() - store.accept(ChatsStore.Intent.LoadMore) - advanceUntilIdle() - assertEquals(20, visibleArchive(store).size) - assertFalse(store.state.hasMore) - assertEquals( - archiveCalls, - client.folderCalls.count { it == ARCHIVE_FOLDER_WIRE_ID }, - ) - } finally { - store.dispose() + fun customFolderKeepsArchivedMembersAfterChatsChanged() = + runTest { + val archived = + Chat( + PeerId(99), + "Archived late member", + archived = true, + unreadCount = 2, + lastMessageDate = 99, + lastMessageId = 1, + ) + val client = + StubClient( + chats = + Outcome.Ok( + (1L..40L).map { id -> + Chat( + PeerId(id), + "Initial archive $id", + archived = true, + lastMessageDate = id, + lastMessageId = 1, + ) + }, + ), + ).apply { + archivePages.addLast( + (100L..139L).map { id -> + Chat( + PeerId(id), + "Archive $id", + archived = true, + lastMessageDate = id, + lastMessageId = 1 + ) + }, + ) + archivePages.addLast(listOf(archived)) + } + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + store.accept(ChatsStore.Intent.FolderSelected(7)) + advanceUntilIdle() + val folder = + Folder( + id = 7, + title = "Work", + chatIds = listOf(archived.id), + excludeArchived = true, + ) + assertEquals( + listOf(99L), + visibleChats(store.state.chats, listOf(folder), 7).map { it.id.value }, + ) + client.events.emit(MtprotoUpdate.ChatsChanged(listOf(archived.copy(title = "Renamed")))) + advanceUntilIdle() + assertEquals( + listOf(99L), + visibleChats(store.state.chats, listOf(folder), 7).map { it.id.value }, + ) + } finally { + store.dispose() + } } - } @Test - fun customFolderKeepsArchivedMembersAfterChatsChanged() = runTest { - val archived = Chat( - PeerId(99), - "Archived late member", - archived = true, - unreadCount = 2, - lastMessageDate = 99, - lastMessageId = 1, - ) - val client = StubClient( - chats = Outcome.Ok( + fun incomingChatUpdatePreservesUnfinishedNetworkPagination() = + runTest { + val initial = (1L..40L).map { id -> Chat( PeerId(id), - "Initial archive $id", + "Chat $id", archived = true, lastMessageDate = id, lastMessageId = 1, ) - }, - ), - ).apply { - archivePages.addLast((100L..139L).map { id -> - Chat(PeerId(id), "Archive $id", archived = true, lastMessageDate = id, lastMessageId = 1) - }) - archivePages.addLast(listOf(archived)) - } - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - ).create() - try { - store.accept(ChatsStore.Intent.FolderSelected(7)) - advanceUntilIdle() - val folder = Folder( - id = 7, - title = "Work", - chatIds = listOf(archived.id), - excludeArchived = true, - ) - assertEquals( - listOf(99L), - visibleChats(store.state.chats, listOf(folder), 7).map { it.id.value }, - ) - client.events.emit(MtprotoUpdate.ChatsChanged(listOf(archived.copy(title = "Renamed")))) - advanceUntilIdle() - assertEquals( - listOf(99L), - visibleChats(store.state.chats, listOf(folder), 7).map { it.id.value }, - ) - } finally { - store.dispose() - } - } - - @Test - fun incomingChatUpdatePreservesUnfinishedNetworkPagination() = runTest { - val initial = (1L..40L).map { id -> - Chat( - PeerId(id), - "Chat $id", - archived = true, - lastMessageDate = id, - lastMessageId = 1, - ) - } - val client = StubClient(chats = Outcome.Ok(initial)) - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - ).create() - try { - advanceUntilIdle() - assertTrue(store.state.hasMore) - client.events.emit(MtprotoUpdate.ChatsChanged(listOf(initial.first().copy(title = "Updated")))) - advanceUntilIdle() - assertTrue(store.state.hasMore) - } finally { - store.dispose() + } + val client = StubClient(chats = Outcome.Ok(initial)) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + assertTrue(store.state.hasMore) + client.events.emit( + MtprotoUpdate.ChatsChanged( + listOf( + initial.first().copy(title = "Updated") + ) + ) + ) + advanceUntilIdle() + assertTrue(store.state.hasMore) + } finally { + store.dispose() + } } - } @Test - fun refreshDuringNetworkRefreshRunsOneCoalescedFollowUp() = runTest { - val initial = Chat(PeerId(1), "Initial", lastMessageId = 1) - val client = StubClient(chats = Outcome.Ok(listOf(initial))).apply { - chatResponses.addLast( - Outcome.Ok( - listOf( - initial.copy( - title = "First", - lastMessageId = 2 - ) + fun refreshDuringNetworkRefreshRunsOneCoalescedFollowUp() = + runTest { + val initial = Chat(PeerId(1), "Initial", lastMessageId = 1) + val client = + StubClient(chats = Outcome.Ok(listOf(initial))).apply { + chatResponses.addLast( + Outcome.Ok( + listOf( + initial.copy( + title = "First", + lastMessageId = 2, + ), + ), + ), ) - ) - ) - chatResponses.addLast( - Outcome.Ok( - listOf( - initial.copy( - title = "Second", - lastMessageId = 3 - ) + chatResponses.addLast( + Outcome.Ok( + listOf( + initial.copy( + title = "Second", + lastMessageId = 3, + ), + ), + ), ) + } + val release = CompletableDeferred() + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + client.chatsGate = release + store.accept(ChatsStore.Intent.Refresh) + runCurrent() + assertEquals(2, client.getChatsCalls) + store.accept(ChatsStore.Intent.Refresh) + release.complete(Unit) + advanceUntilIdle() + assertEquals(3, client.getChatsCalls) + assertEquals( + 3, + store.state.chats + .single() + .lastMessageId, ) - ) - } - val release = CompletableDeferred() - val store = ChatsStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - ).create() - try { - advanceUntilIdle() - client.chatsGate = release - store.accept(ChatsStore.Intent.Refresh) - runCurrent() - assertEquals(2, client.getChatsCalls) - store.accept(ChatsStore.Intent.Refresh) - release.complete(Unit) - advanceUntilIdle() - assertEquals(3, client.getChatsCalls) - assertEquals(3, store.state.chats.single().lastMessageId) - } finally { - release.complete(Unit) - store.dispose() + } finally { + release.complete(Unit) + store.dispose() + } } - } @Test - fun refreshMergeLockPreservesReadUpdateBeforePublication() = runTest { - val target = Chat( - PeerId(999), - "Target", - unreadCount = 3, - lastMessageDate = 999, - lastMessageId = 1, - ) - val refreshed = (1L..40L).map { id -> - Chat(PeerId(id), "Chat $id", lastMessageDate = id, lastMessageId = 1) - } + target - val client = StubClient(chats = Outcome.Ok(listOf(target))).apply { - chatResponses.addLast(Outcome.Ok(refreshed)) - } - val mergeEntered = CompletableDeferred() - val releaseMerge = CompletableDeferred() - var hookArmed = false - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup = null, - sessionStore = null, - refreshMergeHook = { - if (hookArmed) { - mergeEntered.complete(Unit) - releaseMerge.await() + fun refreshMergeLockPreservesReadUpdateBeforePublication() = + runTest { + val target = + Chat( + PeerId(999), + "Target", + unreadCount = 3, + lastMessageDate = 999, + lastMessageId = 1, + ) + val refreshed = + (1L..40L).map { id -> + Chat(PeerId(id), "Chat $id", lastMessageDate = id, lastMessageId = 1) + } + target + val client = + StubClient(chats = Outcome.Ok(listOf(target))).apply { + chatResponses.addLast(Outcome.Ok(refreshed)) } - }, - ).create() - try { - advanceUntilIdle() - hookArmed = true - store.accept(ChatsStore.Intent.Refresh) - mergeEntered.await() - store.accept(ChatsStore.Intent.MarkRead(listOf(target.id))) - releaseMerge.complete(Unit) - advanceUntilIdle() - val row = store.state.chats.first { it.id == target.id } - assertEquals(0, row.unreadCount) - assertEquals(1, row.readInboxMaxId) - } finally { - releaseMerge.complete(Unit) - store.dispose() + val mergeEntered = CompletableDeferred() + val releaseMerge = CompletableDeferred() + var hookArmed = false + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + refreshMergeHook = { + if (hookArmed) { + mergeEntered.complete(Unit) + releaseMerge.await() + } + }, + ).create() + try { + advanceUntilIdle() + hookArmed = true + store.accept(ChatsStore.Intent.Refresh) + mergeEntered.await() + store.accept(ChatsStore.Intent.MarkRead(listOf(target.id))) + releaseMerge.complete(Unit) + advanceUntilIdle() + val row = store.state.chats.first { it.id == target.id } + assertEquals(0, row.unreadCount) + assertEquals(1, row.readInboxMaxId) + } finally { + releaseMerge.complete(Unit) + store.dispose() + } } - } @Test - fun roomReadStateWaitsForRefreshPublicationLock() = runTest { - val target = Chat( - PeerId(999), - "Target", - unreadCount = 3, - lastMessageDate = 999, - lastMessageId = 1, - ) - val readStates = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) - val warmup = object : OfflineWarmup(db = null) { - override fun observeReadStates() = readStates - } - val client = StubClient(chats = Outcome.Ok(listOf(target))).apply { - chatResponses.addLast(Outcome.Ok(listOf(target))) - } - val mergeEntered = CompletableDeferred() - val releaseMerge = CompletableDeferred() - val observerAttempted = CompletableDeferred() - val releaseObserver = CompletableDeferred() - var hookArmed = false - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup, - sessionStore = null, - refreshMergeHook = { - if (hookArmed) { - mergeEntered.complete(Unit) - releaseMerge.await() + fun roomReadStateWaitsForRefreshPublicationLock() = + runTest { + val target = + Chat( + PeerId(999), + "Target", + unreadCount = 3, + lastMessageDate = 999, + lastMessageId = 1, + ) + val readStates = + MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) + val warmup = + object : OfflineWarmup(db = null) { + override fun observeReadStates() = readStates } - }, - readStateHook = { - if (hookArmed) { - observerAttempted.complete(Unit) - releaseObserver.await() + val client = + StubClient(chats = Outcome.Ok(listOf(target))).apply { + chatResponses.addLast(Outcome.Ok(listOf(target))) } - }, - ).create() - try { - advanceUntilIdle() - hookArmed = true - store.accept(ChatsStore.Intent.Refresh) - mergeEntered.await() - readStates.emit(listOf(ChatReadState(target.id.value, 1, 0))) - observerAttempted.await() - releaseMerge.complete(Unit) - releaseObserver.complete(Unit) - advanceUntilIdle() - val row = store.state.chats.first { it.id == target.id } - assertEquals(0, row.unreadCount) - assertEquals(1, row.readInboxMaxId) - } finally { - releaseMerge.complete(Unit) - releaseObserver.complete(Unit) - store.dispose() + val mergeEntered = CompletableDeferred() + val releaseMerge = CompletableDeferred() + val observerAttempted = CompletableDeferred() + val releaseObserver = CompletableDeferred() + var hookArmed = false + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + refreshMergeHook = { + if (hookArmed) { + mergeEntered.complete(Unit) + releaseMerge.await() + } + }, + readStateHook = { + if (hookArmed) { + observerAttempted.complete(Unit) + releaseObserver.await() + } + }, + ).create() + try { + advanceUntilIdle() + hookArmed = true + store.accept(ChatsStore.Intent.Refresh) + mergeEntered.await() + readStates.emit(listOf(ChatReadState(target.id.value, 1, 0))) + observerAttempted.await() + releaseMerge.complete(Unit) + releaseObserver.complete(Unit) + advanceUntilIdle() + val row = store.state.chats.first { it.id == target.id } + assertEquals(0, row.unreadCount) + assertEquals(1, row.readInboxMaxId) + } finally { + releaseMerge.complete(Unit) + releaseObserver.complete(Unit) + store.dispose() + } } - } @Test - fun listRebuildDoesNotRestoreUnreadAfterReadUpdate() = runTest { - val targetId = PeerId(999) - val target = Chat( - targetId, - "Target", - unreadCount = 3, - lastMessageDate = 999, - lastMessageId = 1, - ) - val initial = (1L..40L).map { id -> - Chat(PeerId(id), "Chat $id", lastMessageDate = id, lastMessageId = 1) - } + target - val client = StubClient(chats = Outcome.Ok(listOf(target))) - val mergeEntered = CompletableDeferred() - val releaseMerge = CompletableDeferred() - var hookArmed = false - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup = null, - sessionStore = null, - refreshMergeHook = { - if (hookArmed) { - mergeEntered.complete(Unit) - releaseMerge.await() - } - }, - ).create() - try { - advanceUntilIdle() - runCurrent() - client.events.emit(MtprotoUpdate.ChatsChanged(initial)) - runCurrent() - advanceUntilIdle() - hookArmed = true - val rebuild = launch { - client.events.emit( - MtprotoUpdate.ChatsChanged(listOf(target.copy(title = "Updated"))), + fun listRebuildDoesNotRestoreUnreadAfterReadUpdate() = + runTest { + val targetId = PeerId(999) + val target = + Chat( + targetId, + "Target", + unreadCount = 3, + lastMessageDate = 999, + lastMessageId = 1, ) + val initial = + (1L..40L).map { id -> + Chat(PeerId(id), "Chat $id", lastMessageDate = id, lastMessageId = 1) + } + target + val client = StubClient(chats = Outcome.Ok(listOf(target))) + val mergeEntered = CompletableDeferred() + val releaseMerge = CompletableDeferred() + var hookArmed = false + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + refreshMergeHook = { + if (hookArmed) { + mergeEntered.complete(Unit) + releaseMerge.await() + } + }, + ).create() + try { + advanceUntilIdle() + runCurrent() + client.events.emit(MtprotoUpdate.ChatsChanged(initial)) + runCurrent() + advanceUntilIdle() + hookArmed = true + val rebuild = + launch { + client.events.emit( + MtprotoUpdate.ChatsChanged(listOf(target.copy(title = "Updated"))), + ) + } + mergeEntered.await() + store.accept(ChatsStore.Intent.MarkRead(listOf(targetId))) + releaseMerge.complete(Unit) + rebuild.join() + advanceUntilIdle() + client.events.emit(MtprotoUpdate.ReadInbox(targetId, maxId = 2, stillUnread = 0)) + advanceUntilIdle() + val row = store.state.chats.first { it.id == targetId } + assertEquals(0, row.unreadCount) + assertEquals(2, row.readInboxMaxId) + assertTrue(row.title == target.title || row.title == "Updated") + } finally { + releaseMerge.complete(Unit) + store.dispose() } - mergeEntered.await() - store.accept(ChatsStore.Intent.MarkRead(listOf(targetId))) - releaseMerge.complete(Unit) - rebuild.join() - advanceUntilIdle() - client.events.emit(MtprotoUpdate.ReadInbox(targetId, maxId = 2, stillUnread = 0)) - advanceUntilIdle() - val row = store.state.chats.first { it.id == targetId } - assertEquals(0, row.unreadCount) - assertEquals(2, row.readInboxMaxId) - assertTrue(row.title == target.title || row.title == "Updated") - } finally { - releaseMerge.complete(Unit) - store.dispose() } - } @Test fun markReadCallsReadHistoryForUnreadChatsOnly() { val unread = Chat(PeerId(1), "Ada", unreadCount = 3, lastMessageId = 44) val read = Chat(PeerId(2), "Bob", unreadCount = 0, lastMessageId = 7) val client = StubClient(chats = Outcome.Ok(listOf(unread, read))) - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup = null, - sessionStore = null, - ).create() + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() try { store.accept(ChatsStore.Intent.MarkRead(listOf(PeerId(1), PeerId(2)))) assertEquals(listOf(PeerId(1) to 44), client.reads) - assertEquals(0, store.state.chats.first { it.id == PeerId(1) }.unreadCount) + assertEquals( + 0, + store.state.chats + .first { it.id == PeerId(1) } + .unreadCount, + ) } finally { store.dispose() } @@ -1037,606 +1396,1215 @@ class ChatsStoreCacheTest { fun cachedMuteSurvivesMissingNotifyDefaults() { // getNotifySettings is stubbed as an error: the cached effective flag is all there is. val cached = Chat(PeerId(1), "Ada", muted = true, muteOverride = false) - val warmup = object : OfflineWarmup() { - override suspend fun chats() = listOf(cached) - } - val store = ChatsStoreFactory( - DefaultStoreFactory(), - StubClient(chats = Outcome.Err("offline"), failNotifySettings = true), - warmup, - sessionStore = null, - ).create() + val warmup = + object : OfflineWarmup() { + override suspend fun chats() = listOf(cached) + } + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + StubClient(chats = Outcome.Err("offline"), failNotifySettings = true), + warmup, + sessionStore = null, + ).create() try { - assertTrue(store.state.chats.single().muted) + assertTrue( + store.state.chats + .single() + .muted, + ) } finally { store.dispose() } } + @Test + fun dialogsRequestStartsWhileNotifyDefaultsArePending() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val release = CompletableDeferred() + val client = + StubClient( + chats = Outcome.Ok(listOf(Chat(PeerId(1), "Ada"))), + defaults = NotifySettings(muteUntil = Int.MAX_VALUE), + ).apply { notifySettingsGate = release } + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + runCurrent() + assertEquals(1, client.getChatsCalls) + assertEquals(3, client.notifySettingsCalls) + assertTrue(store.state.loading) + release.complete(Unit) + advanceUntilIdle() + assertFalse(store.state.loading) + assertTrue( + store.state.chats + .single() + .muted, + ) + } finally { + release.complete(Unit) + store.dispose() + } + } + + @Test + fun catalogWaitsForProtocolCatchupWithoutHoldingTheVisibleList() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val release = CompletableDeferred() + var catalogStarted = false + val page = (1L..40L).map { id -> + Chat( + PeerId(id), + "Chat $id", + lastMessageDate = id, + lastMessageId = 1 + ) + } + val client = + object : StubClient(chats = Outcome.Ok(page)) { + override suspend fun getChats(): Outcome> { + release.await() + return super.getChats() + } + + override suspend fun loadMoreChats( + offsetDate: Int, + offsetId: Int, + offsetPeerId: Long, + ): Outcome> { + catalogStarted = true + return Outcome.Ok(emptyList()) + } + } + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + runCurrent() + client.events.emit(MtprotoUpdate.SyncState(true)) + release.complete(Unit) + runCurrent() + assertEquals(40, store.state.chats.size) + assertFalse(store.state.loading) + assertTrue(store.state.syncing) + assertFalse(catalogStarted) + client.events.emit(MtprotoUpdate.SyncState(false)) + advanceUntilIdle() + assertTrue(catalogStarted) + assertFalse(store.state.syncing) + } finally { + store.dispose() + release.complete(Unit) + } + } + + @Test + fun catalogPausesBetweenPagesWhenCatchupRestarts() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val release = CompletableDeferred() + var requests = 0 + val seed = (81L..120L).map { id -> + Chat( + PeerId(id), + "Chat $id", + lastMessageDate = id, + lastMessageId = 1 + ) + } + val next = (41L..80L).map { id -> + Chat( + PeerId(id), + "Chat $id", + lastMessageDate = id, + lastMessageId = 1 + ) + } + val client = + object : StubClient(chats = Outcome.Ok(seed)) { + override suspend fun loadMoreChats( + offsetDate: Int, + offsetId: Int, + offsetPeerId: Long, + ): Outcome> { + requests++ + if (requests > 1) return Outcome.Ok(emptyList()) + release.await() + return Outcome.Ok(next) + } + } + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + runCurrent() + assertEquals(1, requests) + client.events.emit(MtprotoUpdate.SyncState(true)) + release.complete(Unit) + runCurrent() + assertEquals(1, requests) + client.events.emit(MtprotoUpdate.SyncState(false)) + advanceUntilIdle() + assertEquals(2, requests) + } finally { + store.dispose() + release.complete(Unit) + } + } + + @Test + fun refreshCancelsCatalogWithoutWaitingForItsNextPage() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val release = CompletableDeferred() + var catalogStarted = false + var catalogCancelled = false + val page = (1L..40L).map { id -> + Chat( + PeerId(id), + "Chat $id", + lastMessageDate = id, + lastMessageId = 1 + ) + } + val client = + object : StubClient(chats = Outcome.Ok(page)) { + override suspend fun loadMoreChats( + offsetDate: Int, + offsetId: Int, + offsetPeerId: Long, + ): Outcome> { + catalogStarted = true + try { + release.await() + return Outcome.Ok(emptyList()) + } finally { + catalogCancelled = true + } + } + } + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + runCurrent() + assertTrue(catalogStarted) + assertFalse(store.state.syncing) + store.accept(ChatsStore.Intent.Refresh) + runCurrent() + assertEquals(2, client.getChatsCalls) + assertTrue(catalogCancelled) + } finally { + store.dispose() + release.complete(Unit) + } + } + + @Test + fun supersededCatalogDoesNotCacheAnUncancellableResponse() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + var pending: kotlin.coroutines.Continuation>>? = null + var pageRequests = 0 + val persisted = mutableListOf() + val page = (1L..40L).map { id -> + Chat( + PeerId(id), + "Chat $id", + lastMessageDate = id, + lastMessageId = 1 + ) + } + val stale = + Chat(PeerId(999), "Superseded catalog", lastMessageDate = 1, lastMessageId = 1) + val warmup = + object : OfflineWarmup() { + override suspend fun upsertChats(chats: List) { + persisted.addAll(chats) + } + } + val client = + object : StubClient(chats = Outcome.Ok(page)) { + override suspend fun loadMoreChats( + offsetDate: Int, + offsetId: Int, + offsetPeerId: Long, + ): Outcome> { + pageRequests++ + return if (pageRequests == 1) { + kotlin.coroutines.suspendCoroutine { pending = it } + } else { + Outcome.Ok(page) + } + } + } + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null + ).create() + try { + runCurrent() + val response = checkNotNull(pending) + store.accept(ChatsStore.Intent.Refresh) + runCurrent() + assertEquals(2, client.getChatsCalls) + pending = null + response.resumeWith(Result.success(Outcome.Ok(listOf(stale)))) + advanceUntilIdle() + assertTrue(persisted.none { it.id == stale.id }) + assertTrue(store.state.chats.none { it.id == stale.id }) + val beforePaging = pageRequests + store.accept(ChatsStore.Intent.LoadMore) + runCurrent() + assertTrue(pageRequests > beforePaging) + } finally { + store.dispose() + pending?.resumeWith(Result.success(Outcome.Ok(emptyList()))) + } + } + + @Test + fun incompleteDefaultsPreserveEffectiveMutesAndRetryTogether() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + var broadcastsFail = true + val client = + object : StubClient( + chats = Outcome.Ok( + listOf( + Chat( + PeerId(1), + "Ada", + muted = true, + muteOverride = false + ) + ) + ), + ) { + override suspend fun getNotifySettings( + peerKind: String, + chatId: PeerId, + ): Outcome = + if (peerKind == "broadcasts" && broadcastsFail) { + Outcome.Err("offline") + } else { + Outcome.Ok(NotifySettings()) + } + } + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + advanceUntilIdle() + assertTrue( + store.state.chats + .single() + .muted, + ) + broadcastsFail = false + store.accept(ChatsStore.Intent.Refresh) + advanceUntilIdle() + assertFalse( + store.state.chats + .single() + .muted, + ) + } finally { + store.dispose() + } + } + + @Test + fun disposingStoreCancelsPendingDialogsAndDefaults() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val dialogsGate = CompletableDeferred() + val defaultsGate = CompletableDeferred() + var dialogsCancelled = false + var defaultsCancelled = false + val client = + object : StubClient(chats = Outcome.Ok(emptyList())) { + override suspend fun getChats(): Outcome> = + try { + dialogsGate.await() + Outcome.Ok(listOf(Chat(PeerId(1), "Ada"))) + } finally { + dialogsCancelled = true + } + + override suspend fun getNotifySettings( + peerKind: String, + chatId: PeerId, + ): Outcome = + try { + defaultsGate.await() + Outcome.Ok(NotifySettings()) + } finally { + defaultsCancelled = true + } + } + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + runCurrent() + store.dispose() + runCurrent() + assertTrue(dialogsCancelled) + assertTrue(defaultsCancelled) + } + @Test fun ownMuteSettingWinsOverTheTypeDefaultFromTheServer() { - val client = StubClient( - chats = Outcome.Ok( - listOf( - Chat(PeerId(1), "Ada", muted = false, muteOverride = true), - Chat(PeerId(2), "Bob", muted = false, muteOverride = false), - ), - ), - defaults = NotifySettings(muteUntil = Int.MAX_VALUE), - ) - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup = null, - sessionStore = null, - ).create() + val client = + StubClient( + chats = + Outcome.Ok( + listOf( + Chat(PeerId(1), "Ada", muted = false, muteOverride = true), + Chat(PeerId(2), "Bob", muted = false, muteOverride = false), + ), + ), + defaults = NotifySettings(muteUntil = Int.MAX_VALUE), + ) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() try { // The dialog without its own setting inherits the muted "users" default; the one with // an own setting stays unmuted. - assertFalse(store.state.chats.first { it.id == PeerId(1) }.muted) - assertTrue(store.state.chats.first { it.id == PeerId(2) }.muted) + assertFalse( + store.state.chats + .first { it.id == PeerId(1) } + .muted, + ) + assertTrue( + store.state.chats + .first { it.id == PeerId(2) } + .muted, + ) } finally { store.dispose() } } @Test - fun explicitUnmuteStaysUnmutedAfterDefaultsAndAnotherDialogsPage() = runTest { - val channel = Chat( - PeerId(-100), - "News", - isChannel = true, - muted = false, - muteOverride = true, - lastMessageId = 8, - ) - val client = StubClient( - chats = Outcome.Ok(listOf(channel)), - defaults = NotifySettings(muteUntil = Int.MAX_VALUE), - ) - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup = null, - sessionStore = null, - ).create() - try { - assertFalse(store.state.chats.single().muted) - client.events.emit( - MtprotoUpdate.ChatsChanged( - listOf(channel.copy(muted = false, muteOverride = false)), - ), - ) - val row = store.state.chats.single() - assertFalse(row.muted) - assertTrue(row.muteOverride) - } finally { - store.dispose() + fun explicitUnmuteStaysUnmutedAfterDefaultsAndAnotherDialogsPage() = + runTest { + val channel = + Chat( + PeerId(-100), + "News", + isChannel = true, + muted = false, + muteOverride = true, + lastMessageId = 8, + ) + val client = + StubClient( + chats = Outcome.Ok(listOf(channel)), + defaults = NotifySettings(muteUntil = Int.MAX_VALUE), + ) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + assertFalse( + store.state.chats + .single() + .muted, + ) + client.events.emit( + MtprotoUpdate.ChatsChanged( + listOf(channel.copy(muted = false, muteOverride = false)), + ), + ) + val row = store.state.chats.single() + assertFalse(row.muted) + assertTrue(row.muteOverride) + } finally { + store.dispose() + } } - } @Test - fun inheritedNotifyUpdateDoesNotUnmuteAMutedChannel() = runTest { - val channel = Chat( - PeerId(-100), - "News", - isChannel = true, - muted = true, - muteOverride = true, - ) - val client = StubClient(chats = Outcome.Ok(listOf(channel))) - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup = null, - sessionStore = null, - ).create() - try { - client.events.emit( - MtprotoUpdate.NotifySettingsChanged( - peerKind = "peer", - chatId = channel.id, - muteUntil = 0, - muteSpecified = false, - ), - ) - assertTrue(store.state.chats.single().muted) - assertTrue(store.state.chats.single().muteOverride) - } finally { - store.dispose() + fun inheritedNotifyUpdateDoesNotUnmuteAMutedChannel() = + runTest { + val channel = + Chat( + PeerId(-100), + "News", + isChannel = true, + muted = true, + muteOverride = true, + ) + val client = StubClient(chats = Outcome.Ok(listOf(channel))) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + client.events.emit( + MtprotoUpdate.NotifySettingsChanged( + peerKind = "peer", + chatId = channel.id, + muteUntil = 0, + muteSpecified = false, + ), + ) + assertTrue( + store.state.chats + .single() + .muted, + ) + assertTrue( + store.state.chats + .single() + .muteOverride, + ) + } finally { + store.dispose() + } + } + + @Test + fun peerNotifyChangeUpdatesTheRowAndTheCache() = + runTest { + val upserted = mutableListOf() + val warmup = + object : OfflineWarmup() { + override suspend fun chats() = emptyList() + + override suspend fun upsertChats(chats: List) { + upserted += chats + } + } + val client = StubClient(chats = Outcome.Ok(listOf(Chat(PeerId(1), "Ada")))) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + ).create() + try { + client.events.emit( + MtprotoUpdate.NotifySettingsChanged( + peerKind = "peer", + chatId = PeerId(1), + muteUntil = Int.MAX_VALUE, + ), + ) + val updated = store.state.chats.single() + assertTrue(updated.muted) + assertTrue(updated.muteOverride) + assertTrue(upserted.isNotEmpty()) + assertEquals(PeerId(1), upserted.last().id) + assertTrue(upserted.last().muted) + assertTrue(upserted.last().muteOverride) + } finally { + store.dispose() + } } - } @Test - fun peerNotifyChangeUpdatesTheRowAndTheCache() = runTest { - val upserted = mutableListOf() - val warmup = object : OfflineWarmup() { - override suspend fun chats() = emptyList() - override suspend fun upsertChats(chats: List) { - upserted += chats + fun typeNotifyChangeReloadsTheDefaults() = + runTest { + val client = StubClient(chats = Outcome.Ok(listOf(Chat(PeerId(1), "Ada")))) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + ).create() + try { + val before = client.notifySettingsCalls + client.events.emit( + MtprotoUpdate.NotifySettingsChanged( + peerKind = "chats", + chatId = PeerId(0), + muteUntil = Int.MAX_VALUE, + ), + ) + assertTrue(client.notifySettingsCalls > before) + } finally { + store.dispose() } } - val client = StubClient(chats = Outcome.Ok(listOf(Chat(PeerId(1), "Ada")))) - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup, - sessionStore = null, - ).create() - try { - client.events.emit( - MtprotoUpdate.NotifySettingsChanged( - peerKind = "peer", - chatId = PeerId(1), - muteUntil = Int.MAX_VALUE, - ), - ) - val updated = store.state.chats.single() - assertTrue(updated.muted) - assertTrue(updated.muteOverride) - assertTrue(upserted.isNotEmpty()) - assertEquals(PeerId(1), upserted.last().id) - assertTrue(upserted.last().muted) - assertTrue(upserted.last().muteOverride) - } finally { - store.dispose() - } - } @Test - fun typeNotifyChangeReloadsTheDefaults() = runTest { - val client = StubClient(chats = Outcome.Ok(listOf(Chat(PeerId(1), "Ada")))) - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup = null, - sessionStore = null, - ).create() - try { - val before = client.notifySettingsCalls - client.events.emit( - MtprotoUpdate.NotifySettingsChanged( - peerKind = "chats", - chatId = PeerId(0), - muteUntil = Int.MAX_VALUE, - ), - ) - assertTrue(client.notifySettingsCalls > before) - } finally { - store.dispose() - } - } + fun refreshPersistsLeftNetworkDialogsAndDropsThemFromTheList() = + runTest { + val leaked = + Chat( + PeerId(-100), + "/vg/ comments", + isGroup = true, + lastMessageDate = 50, + lastMessageId = 6, + unreadCount = 6, + ) + val anna = Chat(PeerId(1), "Anna", lastMessageDate = 10, lastMessageId = 2) + val upserted = mutableListOf() + val warmup = + object : OfflineWarmup() { + override suspend fun chats() = listOf(anna, leaked) - @Test - fun refreshPersistsLeftNetworkDialogsAndDropsThemFromTheList() = runTest { - val leaked = Chat( - PeerId(-100), - "/vg/ comments", - isGroup = true, - lastMessageDate = 50, - lastMessageId = 6, - unreadCount = 6, - ) - val anna = Chat(PeerId(1), "Anna", lastMessageDate = 10, lastMessageId = 2) - val upserted = mutableListOf() - val warmup = object : OfflineWarmup() { - override suspend fun chats() = listOf(anna, leaked) - override suspend fun upsertChats(chats: List) { - upserted += chats + override suspend fun upsertChats(chats: List) { + upserted += chats + } + } + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + StubClient(chats = Outcome.Ok(listOf(anna, leaked.copy(left = true)))), + warmup, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + assertTrue(store.state.chats.none { it.id == leaked.id }) + assertEquals(listOf(1L), store.state.chats.map { it.id.value }) + assertTrue(upserted.any { it.id == leaked.id && it.left }) + } finally { + store.dispose() } } - val store = ChatsStoreFactory( - DefaultStoreFactory(), - StubClient(chats = Outcome.Ok(listOf(anna, leaked.copy(left = true)))), - warmup, - sessionStore = null, - ).create() - try { - advanceUntilIdle() - assertTrue(store.state.chats.none { it.id == leaked.id }) - assertEquals(listOf(1L), store.state.chats.map { it.id.value }) - assertTrue(upserted.any { it.id == leaked.id && it.left }) - } finally { - store.dispose() - } - } @Test - fun incomingMessageDoesNotPromoteALeftCommentGroup() = runTest { - val comments = Chat( - PeerId(-100), - "Comments", - isGroup = true, - left = true, - lastMessageDate = 5, - lastMessageId = 1, - ) - val anna = Chat(PeerId(1), "Anna", lastMessageDate = 10, lastMessageId = 2) - val upserted = mutableListOf() - val warmup = object : OfflineWarmup() { - override suspend fun chats() = listOf(anna, comments) - override suspend fun chat(chatId: PeerId) = chats().firstOrNull { it.id == chatId } - override suspend fun upsertChats(chats: List) { - upserted += chats + fun deletedPreviewUpdatesWithoutRepeatingCommittedDeletesAndPersistsLiveDeletes() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + for (persisted in listOf(true, false)) { + val chatId = PeerId(1) + val previous = + Message(MessageId(chatId, 1), null, "previous", 10L, outgoing = false) + val latest = previous.copy(id = MessageId(chatId, 2), text = "deleted", date = 20L) + val chat = Chat( + chatId, + "Ada", + lastMessageId = 2, + lastMessagePreview = "deleted", + lastMessageDate = 20L + ) + var cached = listOf(latest, previous) + val deletes = mutableListOf>>() + val warmup = + object : OfflineWarmup() { + override suspend fun messages( + chatId: PeerId, + limit: Int, + ) = cached.take(limit) + + override suspend fun deleteMessages( + chatId: PeerId?, + messageIds: Collection, + ) { + deletes += chatId to messageIds.toList() + cached = cached.filterNot { it.id.id in messageIds } + } + } + val client = StubClient(chats = Outcome.Ok(listOf(chat))) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null + ).create() + try { + advanceUntilIdle() + assertEquals( + 2, + store.state.chats + .single() + .lastMessageId, + ) + if (persisted) cached = listOf(previous) + val deletion = MtprotoUpdate.MessagesDeleted(chatId, listOf(2)) + assertFalse(deletion.persisted) + client.events.emit(deletion.copy(persisted = persisted)) + advanceUntilIdle() + val shown = store.state.chats.single() + assertEquals(1, shown.lastMessageId) + assertEquals("previous", shown.lastMessagePreview) + assertEquals(10L, shown.lastMessageDate) + assertEquals( + if (persisted) emptyList() else listOf(chatId to listOf(2)), + deletes + ) + assertEquals(listOf(previous), cached) + } finally { + store.dispose() + } } } - val client = StubClient(chats = Outcome.Ok(listOf(anna))) - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup, - sessionStore = null, - ).create() - try { - advanceUntilIdle() - client.events.emit( - MtprotoUpdate.NewMessage( - Message( - id = MessageId(comments.id, 8), - senderId = null, - text = "hi", - date = 99L, - outgoing = false, + + @Test + fun incomingMessageDoesNotPromoteALeftCommentGroup() = + runTest { + val comments = + Chat( + PeerId(-100), + "Comments", + isGroup = true, + left = true, + lastMessageDate = 5, + lastMessageId = 1, + ) + val anna = Chat(PeerId(1), "Anna", lastMessageDate = 10, lastMessageId = 2) + val upserted = mutableListOf() + val warmup = + object : OfflineWarmup() { + override suspend fun chats() = listOf(anna, comments) + + override suspend fun chat(chatId: PeerId) = + chats().firstOrNull { it.id == chatId } + + override suspend fun upsertChats(chats: List) { + upserted += chats + } + } + val client = StubClient(chats = Outcome.Ok(listOf(anna))) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + ).create() + try { + advanceUntilIdle() + client.events.emit( + MtprotoUpdate.NewMessage( + Message( + id = MessageId(comments.id, 8), + senderId = null, + text = "hi", + date = 99L, + outgoing = false, + ), ), - ), - ) - advanceUntilIdle() - assertTrue(store.state.chats.none { it.id == comments.id }) - assertTrue(upserted.none { it.id == comments.id && !it.left }) - } finally { - store.dispose() + ) + advanceUntilIdle() + assertTrue(store.state.chats.none { it.id == comments.id }) + assertTrue(upserted.none { it.id == comments.id && !it.left }) + } finally { + store.dispose() + } } - } @Test - fun metadataPageRewritesTheCacheOnlyWhenMuteStateMoved() = runTest { - val upserted = mutableListOf>() - val warmup = object : OfflineWarmup() { - override suspend fun chats() = emptyList() - override suspend fun upsertChats(chats: List) { - upserted += chats + fun metadataPageRewritesTheCacheOnlyWhenMuteStateMoved() = + runTest { + val upserted = mutableListOf>() + val warmup = + object : OfflineWarmup() { + override suspend fun chats() = emptyList() + + override suspend fun upsertChats(chats: List) { + upserted += chats + } + } + val client = StubClient(chats = Outcome.Ok(listOf(Chat(PeerId(1), "Ada")))) + val store = + ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup, + sessionStore = null, + ).create() + try { + upserted.clear() + val mutedByServer = Chat(PeerId(1), "Ada", muted = true, muteOverride = true) + + // A page that carries a mute the cache does not know yet is written through. + client.events.emit(MtprotoUpdate.ChatsChanged(listOf(mutedByServer))) + assertTrue( + store.state.chats + .single() + .muted, + ) + assertEquals(1, upserted.size) + assertTrue(upserted.single().single().muted) + + // A repeated metadata page (typing, presence) must not rewrite the row. + client.events.emit(MtprotoUpdate.ChatsChanged(listOf(mutedByServer))) + assertEquals(1, upserted.size) + } finally { + store.dispose() } } - val client = StubClient(chats = Outcome.Ok(listOf(Chat(PeerId(1), "Ada")))) - val store = ChatsStoreFactory( - DefaultStoreFactory(), - client, - warmup, - sessionStore = null, - ).create() - try { - upserted.clear() - val mutedByServer = Chat(PeerId(1), "Ada", muted = true, muteOverride = true) - - // A page that carries a mute the cache does not know yet is written through. - client.events.emit(MtprotoUpdate.ChatsChanged(listOf(mutedByServer))) - assertTrue(store.state.chats.single().muted) - assertEquals(1, upserted.size) - assertTrue(upserted.single().single().muted) - - // A repeated metadata page (typing, presence) must not rewrite the row. - client.events.emit(MtprotoUpdate.ChatsChanged(listOf(mutedByServer))) - assertEquals(1, upserted.size) - } finally { - store.dispose() - } - } @Test - fun debounceThenFillsPeopleChatsAndMessages() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val client = StubClient(chats = Outcome.Ok(listOf(Chat(PeerId(1), "Ada")))) - client.contactsPage = Outcome.Ok( - ContactsSearch( - people = listOf(SearchPeer(PeerId(2), "Bob", username = "bob", kind = "user")), - chats = listOf( - SearchPeer(PeerId(-100), "News", username = "news", kind = "channel", isChannel = true), - ), - ), - ) - client.globalPages.add( - Outcome.Ok( - GlobalMessageSearch( - messages = listOf(searchMessage(9, -3)), - nextRate = 11, - nextPeerId = PeerId(-3), - nextOffsetId = 9, + fun debounceThenFillsPeopleChatsAndMessages() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val client = StubClient(chats = Outcome.Ok(listOf(Chat(PeerId(1), "Ada")))) + client.contactsPage = + Outcome.Ok( + ContactsSearch( + people = listOf( + SearchPeer( + PeerId(2), + "Bob", + username = "bob", + kind = "user" + ) + ), + chats = + listOf( + SearchPeer( + PeerId(-100), + "News", + username = "news", + kind = "channel", + isChannel = true + ), + ), + ), + ) + client.globalPages.add( + Outcome.Ok( + GlobalMessageSearch( + messages = listOf(searchMessage(9, -3)), + nextRate = 11, + nextPeerId = PeerId(-3), + nextOffsetId = 9, + ), ), - ), - ) - val store = ChatsStoreFactory(DefaultStoreFactory(), client, warmup = null, sessionStore = null).create() - try { - store.accept(ChatsStore.Intent.QueryChanged("bo")) - runCurrent() - assertTrue(client.contactsQueries.isEmpty()) - advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) - runCurrent() - assertEquals(listOf("bo"), client.contactsQueries) - assertEquals("Bob", store.state.searchPeople.single().title) - assertEquals("News", store.state.searchChats.single().title) - assertEquals(9, store.state.searchMessages.single().id.id) - assertFalse(store.state.searchHasMore) - assertEquals(0, client.globalCalls.single().folderId) - assertFalse(store.state.searchLoading) - } finally { - store.dispose() + ) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + store.accept(ChatsStore.Intent.QueryChanged("bo")) + runCurrent() + assertTrue(client.contactsQueries.isEmpty()) + advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) + runCurrent() + assertEquals(listOf("bo"), client.contactsQueries) + assertEquals( + "Bob", + store.state.searchPeople + .single() + .title, + ) + assertEquals( + "News", + store.state.searchChats + .single() + .title, + ) + assertEquals( + 9, + store.state.searchMessages + .single() + .id.id, + ) + assertFalse(store.state.searchHasMore) + assertEquals(0, client.globalCalls.single().folderId) + assertFalse(store.state.searchLoading) + } finally { + store.dispose() + } } - } @Test - fun emptyQueryCancelsInFlightSearch() = runTest { - val client = StubClient(chats = Outcome.Ok(listOf(Chat(PeerId(1), "Ada")))) - client.searchGate = CompletableDeferred() - client.contactsPage = Outcome.Ok( - ContactsSearch(people = listOf(SearchPeer(PeerId(2), "Bob", kind = "user"))), - ) - val store = ChatsStoreFactory(DefaultStoreFactory(), client, warmup = null, sessionStore = null).create() - try { - store.accept(ChatsStore.Intent.QueryChanged("bo")) - store.accept(ChatsStore.Intent.RetrySearch) - runCurrent() - assertTrue(store.state.searchLoading) - store.accept(ChatsStore.Intent.QueryChanged("")) - runCurrent() - assertTrue(store.state.searchPeople.isEmpty()) - assertTrue(store.state.searchMessages.isEmpty()) - assertFalse(store.state.searchLoading) - assertNull(store.state.searchError) - } finally { - store.dispose() + fun emptyQueryCancelsInFlightSearch() = + runTest { + val client = StubClient(chats = Outcome.Ok(listOf(Chat(PeerId(1), "Ada")))) + client.searchGate = CompletableDeferred() + client.contactsPage = + Outcome.Ok( + ContactsSearch(people = listOf(SearchPeer(PeerId(2), "Bob", kind = "user"))), + ) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + store.accept(ChatsStore.Intent.QueryChanged("bo")) + store.accept(ChatsStore.Intent.RetrySearch) + runCurrent() + assertTrue(store.state.searchLoading) + store.accept(ChatsStore.Intent.QueryChanged("")) + runCurrent() + assertTrue(store.state.searchPeople.isEmpty()) + assertTrue(store.state.searchMessages.isEmpty()) + assertFalse(store.state.searchLoading) + assertNull(store.state.searchError) + } finally { + store.dispose() + } } - } @Test - fun newerQueryDropsStaleResults() = runTest { - val staleGate = CompletableDeferred() - val client = StubClient(chats = Outcome.Ok(emptyList())) - client.searchGate = staleGate - client.contactsPage = Outcome.Ok( - ContactsSearch(people = listOf(SearchPeer(PeerId(2), "Bob", kind = "user"))), - ) - val store = ChatsStoreFactory(DefaultStoreFactory(), client, warmup = null, sessionStore = null).create() - try { - store.accept(ChatsStore.Intent.QueryChanged("bo")) - store.accept(ChatsStore.Intent.RetrySearch) - runCurrent() - assertTrue(store.state.searchLoading) - client.searchGate = null - client.contactsPage = Outcome.Ok( - ContactsSearch(people = listOf(SearchPeer(PeerId(3), "Cara", kind = "user"))), - ) - store.accept(ChatsStore.Intent.QueryChanged("ca")) - store.accept(ChatsStore.Intent.RetrySearch) - runCurrent() - assertEquals(listOf("Cara"), store.state.searchPeople.map { it.title }) - staleGate.complete(Unit) - runCurrent() - assertEquals(listOf("Cara"), store.state.searchPeople.map { it.title }) - assertEquals(listOf("bo", "ca"), client.contactsQueries) - } finally { - store.dispose() + fun newerQueryDropsStaleResults() = + runTest { + val staleGate = CompletableDeferred() + val client = StubClient(chats = Outcome.Ok(emptyList())) + client.searchGate = staleGate + client.contactsPage = + Outcome.Ok( + ContactsSearch(people = listOf(SearchPeer(PeerId(2), "Bob", kind = "user"))), + ) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + store.accept(ChatsStore.Intent.QueryChanged("bo")) + store.accept(ChatsStore.Intent.RetrySearch) + runCurrent() + assertTrue(store.state.searchLoading) + client.searchGate = null + client.contactsPage = + Outcome.Ok( + ContactsSearch( + people = listOf( + SearchPeer( + PeerId(3), + "Cara", + kind = "user" + ) + ) + ), + ) + store.accept(ChatsStore.Intent.QueryChanged("ca")) + store.accept(ChatsStore.Intent.RetrySearch) + runCurrent() + assertEquals(listOf("Cara"), store.state.searchPeople.map { it.title }) + staleGate.complete(Unit) + runCurrent() + assertEquals(listOf("Cara"), store.state.searchPeople.map { it.title }) + assertEquals(listOf("bo", "ca"), client.contactsQueries) + } finally { + store.dispose() + } } - } @Test - fun pagesGlobalMessagesWithOffsetTriple() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val client = StubClient(chats = Outcome.Ok(emptyList())) - client.contactsPage = Outcome.Ok(ContactsSearch()) - val first = (1..GLOBAL_SEARCH_LIMIT).map { searchMessage(it, -4) } - client.globalPages.add( - Outcome.Ok( - GlobalMessageSearch( - messages = first, - nextRate = 44, - nextPeerId = PeerId(-4), - nextOffsetId = GLOBAL_SEARCH_LIMIT, + fun pagesGlobalMessagesWithOffsetTriple() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val client = StubClient(chats = Outcome.Ok(emptyList())) + client.contactsPage = Outcome.Ok(ContactsSearch()) + val first = (1..GLOBAL_SEARCH_LIMIT).map { searchMessage(it, -4) } + client.globalPages.add( + Outcome.Ok( + GlobalMessageSearch( + messages = first, + nextRate = 44, + nextPeerId = PeerId(-4), + nextOffsetId = GLOBAL_SEARCH_LIMIT, + ), ), - ), - ) - client.globalPages.add( - Outcome.Ok( - GlobalMessageSearch( - messages = listOf(searchMessage(21, -5)), - nextRate = 0, - nextPeerId = PeerId(0), - nextOffsetId = 0, + ) + client.globalPages.add( + Outcome.Ok( + GlobalMessageSearch( + messages = listOf(searchMessage(21, -5)), + nextRate = 0, + nextPeerId = PeerId(0), + nextOffsetId = 0, + ), ), - ), - ) - val store = ChatsStoreFactory(DefaultStoreFactory(), client, warmup = null, sessionStore = null).create() - try { - store.accept(ChatsStore.Intent.QueryChanged("hi")) - advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) - runCurrent() - assertTrue(store.state.searchHasMore) - assertEquals(GLOBAL_SEARCH_LIMIT, store.state.searchMessages.size) - store.accept(ChatsStore.Intent.LoadMore) - runCurrent() - assertEquals(GLOBAL_SEARCH_LIMIT + 1, store.state.searchMessages.size) - assertEquals(44, client.globalCalls[1].offsetRate) - assertEquals(-4L, client.globalCalls[1].offsetPeerId) - assertEquals(GLOBAL_SEARCH_LIMIT, client.globalCalls[1].offsetId) - assertFalse(store.state.searchHasMore) - } finally { - store.dispose() + ) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + store.accept(ChatsStore.Intent.QueryChanged("hi")) + advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) + runCurrent() + assertTrue(store.state.searchHasMore) + assertEquals(GLOBAL_SEARCH_LIMIT, store.state.searchMessages.size) + store.accept(ChatsStore.Intent.LoadMore) + runCurrent() + assertEquals(GLOBAL_SEARCH_LIMIT + 1, store.state.searchMessages.size) + assertEquals(44, client.globalCalls[1].offsetRate) + assertEquals(-4L, client.globalCalls[1].offsetPeerId) + assertEquals(GLOBAL_SEARCH_LIMIT, client.globalCalls[1].offsetId) + assertFalse(store.state.searchHasMore) + } finally { + store.dispose() + } } - } @Test - fun archiveSearchSendsFolderId() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val client = StubClient(chats = Outcome.Ok(emptyList())) - client.contactsPage = Outcome.Ok(ContactsSearch()) - val store = ChatsStoreFactory(DefaultStoreFactory(), client, warmup = null, sessionStore = null).create() - try { - store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) - store.accept(ChatsStore.Intent.QueryChanged("hi")) - advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) - runCurrent() - assertEquals(ARCHIVE_FOLDER_WIRE_ID, client.globalCalls.single().folderId) - } finally { - store.dispose() + fun archiveSearchSendsFolderId() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val client = StubClient(chats = Outcome.Ok(emptyList())) + client.contactsPage = Outcome.Ok(ContactsSearch()) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + store.accept(ChatsStore.Intent.FolderSelected(ARCHIVE_FOLDER_ID)) + store.accept(ChatsStore.Intent.QueryChanged("hi")) + advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) + runCurrent() + assertEquals(ARCHIVE_FOLDER_WIRE_ID, client.globalCalls.single().folderId) + } finally { + store.dispose() + } } - } @Test - fun emptySearchQueryIsNotRetried() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val client = StubClient(chats = Outcome.Ok(emptyList())) - client.contactsPage = Outcome.Ok(ContactsSearch()) - val emptyQuery = TelegramError.parse("SEARCH_QUERY_EMPTY") - client.globalPages.add(Outcome.Err(emptyQuery.message, telegram = emptyQuery)) - val store = ChatsStoreFactory(DefaultStoreFactory(), client, warmup = null, sessionStore = null).create() - try { - store.accept(ChatsStore.Intent.QueryChanged("x")) - advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) - runCurrent() - assertNull(store.state.searchError) - assertEquals(1, client.globalCalls.size) - } finally { - store.dispose() + fun emptySearchQueryIsNotRetried() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val client = StubClient(chats = Outcome.Ok(emptyList())) + client.contactsPage = Outcome.Ok(ContactsSearch()) + val emptyQuery = TelegramError.parse("SEARCH_QUERY_EMPTY") + client.globalPages.add(Outcome.Err(emptyQuery.message, telegram = emptyQuery)) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + store.accept(ChatsStore.Intent.QueryChanged("x")) + advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) + runCurrent() + assertNull(store.state.searchError) + assertEquals(1, client.globalCalls.size) + } finally { + store.dispose() + } } - } @Test - fun otherErrorsSurfaceForRetry() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val client = StubClient(chats = Outcome.Ok(emptyList())) - client.contactsPage = Outcome.Ok(ContactsSearch()) - client.globalPages.add(Outcome.Err("Timeout")) - val store = ChatsStoreFactory(DefaultStoreFactory(), client, warmup = null, sessionStore = null).create() - try { - store.accept(ChatsStore.Intent.QueryChanged("x")) - advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) - runCurrent() - assertEquals(TelegramError.Kind.Network, store.state.searchError?.kind) - client.globalPages.add( - Outcome.Ok(GlobalMessageSearch(messages = listOf(searchMessage(1, 8)))), - ) - store.accept(ChatsStore.Intent.RetrySearch) - runCurrent() - assertNull(store.state.searchError) - assertEquals(1, store.state.searchMessages.size) - } finally { - store.dispose() + fun otherErrorsSurfaceForRetry() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val client = StubClient(chats = Outcome.Ok(emptyList())) + client.contactsPage = Outcome.Ok(ContactsSearch()) + client.globalPages.add(Outcome.Err("Timeout")) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + store.accept(ChatsStore.Intent.QueryChanged("x")) + advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) + runCurrent() + assertEquals(TelegramError.Kind.Network, store.state.searchError?.kind) + client.globalPages.add( + Outcome.Ok(GlobalMessageSearch(messages = listOf(searchMessage(1, 8)))), + ) + store.accept(ChatsStore.Intent.RetrySearch) + runCurrent() + assertNull(store.state.searchError) + assertEquals(1, store.state.searchMessages.size) + } finally { + store.dispose() + } } - } @Test - fun failedPageStopsAutomaticPaging() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val client = StubClient(chats = Outcome.Ok(emptyList())) - client.contactsPage = Outcome.Ok(ContactsSearch()) - val first = (1..GLOBAL_SEARCH_LIMIT).map { searchMessage(it, -4) } - client.globalPages.add( - Outcome.Ok( - GlobalMessageSearch( - messages = first, - nextRate = 44, - nextPeerId = PeerId(-4), - nextOffsetId = GLOBAL_SEARCH_LIMIT, + fun failedPageStopsAutomaticPaging() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val client = StubClient(chats = Outcome.Ok(emptyList())) + client.contactsPage = Outcome.Ok(ContactsSearch()) + val first = (1..GLOBAL_SEARCH_LIMIT).map { searchMessage(it, -4) } + client.globalPages.add( + Outcome.Ok( + GlobalMessageSearch( + messages = first, + nextRate = 44, + nextPeerId = PeerId(-4), + nextOffsetId = GLOBAL_SEARCH_LIMIT, + ), ), - ), - ) - client.globalPages.add(Outcome.Err("Timeout")) - val store = ChatsStoreFactory(DefaultStoreFactory(), client, warmup = null, sessionStore = null).create() - try { - store.accept(ChatsStore.Intent.QueryChanged("hi")) - advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) - runCurrent() - assertTrue(store.state.searchHasMore) - store.accept(ChatsStore.Intent.LoadMore) - runCurrent() - assertFalse(store.state.searchHasMore) - assertEquals(TelegramError.Kind.Network, store.state.searchError?.kind) - val calls = client.globalCalls.size - store.accept(ChatsStore.Intent.LoadMore) - runCurrent() - assertEquals(calls, client.globalCalls.size) - } finally { - store.dispose() + ) + client.globalPages.add(Outcome.Err("Timeout")) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + store.accept(ChatsStore.Intent.QueryChanged("hi")) + advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) + runCurrent() + assertTrue(store.state.searchHasMore) + store.accept(ChatsStore.Intent.LoadMore) + runCurrent() + assertFalse(store.state.searchHasMore) + assertEquals(TelegramError.Kind.Network, store.state.searchError?.kind) + val calls = client.globalCalls.size + store.accept(ChatsStore.Intent.LoadMore) + runCurrent() + assertEquals(calls, client.globalCalls.size) + } finally { + store.dispose() + } } - } @Test - fun loadMoreDuringDebounceDoesNotReuseOldOffsets() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val client = StubClient(chats = Outcome.Ok(emptyList())) - client.contactsPage = Outcome.Ok(ContactsSearch()) - val first = (1..GLOBAL_SEARCH_LIMIT).map { searchMessage(it, -4) } - client.globalPages.add( - Outcome.Ok( - GlobalMessageSearch( - messages = first, - nextRate = 44, - nextPeerId = PeerId(-4), - nextOffsetId = GLOBAL_SEARCH_LIMIT, + fun loadMoreDuringDebounceDoesNotReuseOldOffsets() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val client = StubClient(chats = Outcome.Ok(emptyList())) + client.contactsPage = Outcome.Ok(ContactsSearch()) + val first = (1..GLOBAL_SEARCH_LIMIT).map { searchMessage(it, -4) } + client.globalPages.add( + Outcome.Ok( + GlobalMessageSearch( + messages = first, + nextRate = 44, + nextPeerId = PeerId(-4), + nextOffsetId = GLOBAL_SEARCH_LIMIT, + ), ), - ), - ) - val store = ChatsStoreFactory(DefaultStoreFactory(), client, warmup = null, sessionStore = null).create() - try { - store.accept(ChatsStore.Intent.QueryChanged("hi")) - advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) - runCurrent() - assertEquals(1, client.globalCalls.size) - store.accept(ChatsStore.Intent.QueryChanged("yo")) - store.accept(ChatsStore.Intent.LoadMore) - runCurrent() - assertEquals(1, client.globalCalls.size) - assertFalse(store.state.searchHasMore) - } finally { - store.dispose() + ) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + store.accept(ChatsStore.Intent.QueryChanged("hi")) + advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) + runCurrent() + assertEquals(1, client.globalCalls.size) + store.accept(ChatsStore.Intent.QueryChanged("yo")) + store.accept(ChatsStore.Intent.LoadMore) + runCurrent() + assertEquals(1, client.globalCalls.size) + assertFalse(store.state.searchHasMore) + } finally { + store.dispose() + } } - } @Test - fun localDialogsAreDroppedFromPeopleSection() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val local = Chat(PeerId(2), "Bob") - val client = StubClient(chats = Outcome.Ok(listOf(local))) - client.contactsPage = Outcome.Ok( - ContactsSearch( - people = listOf( - SearchPeer(PeerId(2), "Bob", kind = "user"), - SearchPeer(PeerId(3), "Cara", kind = "user"), - ), - ), - ) - val store = ChatsStoreFactory(DefaultStoreFactory(), client, warmup = null, sessionStore = null).create() - try { - store.accept(ChatsStore.Intent.QueryChanged("b")) - advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) - runCurrent() - assertEquals(listOf(3L), store.state.searchPeople.map { it.id.value }) - } finally { - store.dispose() + fun localDialogsAreDroppedFromPeopleSection() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val local = Chat(PeerId(2), "Bob") + val client = StubClient(chats = Outcome.Ok(listOf(local))) + client.contactsPage = + Outcome.Ok( + ContactsSearch( + people = + listOf( + SearchPeer(PeerId(2), "Bob", kind = "user"), + SearchPeer(PeerId(3), "Cara", kind = "user"), + ), + ), + ) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + store.accept(ChatsStore.Intent.QueryChanged("b")) + advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) + runCurrent() + assertEquals(listOf(3L), store.state.searchPeople.map { it.id.value }) + } finally { + store.dispose() + } } - } @Test - fun usernameHitStaysWhenLoadedTitleDoesNotMatch() = runTest { - Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) - val loaded = Chat(PeerId(2), "Ada Lovelace") - val client = StubClient(chats = Outcome.Ok(listOf(loaded))) - client.contactsPage = Outcome.Ok( - ContactsSearch( - people = listOf(SearchPeer(PeerId(2), "Bob", username = "bob", kind = "user")), - ), - ) - val store = ChatsStoreFactory(DefaultStoreFactory(), client, warmup = null, sessionStore = null).create() - try { - store.accept(ChatsStore.Intent.QueryChanged("bob")) - advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) - runCurrent() - assertEquals(listOf(2L), store.state.searchPeople.map { it.id.value }) - } finally { - store.dispose() + fun usernameHitStaysWhenLoadedTitleDoesNotMatch() = + runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val loaded = Chat(PeerId(2), "Ada Lovelace") + val client = StubClient(chats = Outcome.Ok(listOf(loaded))) + client.contactsPage = + Outcome.Ok( + ContactsSearch( + people = listOf( + SearchPeer( + PeerId(2), + "Bob", + username = "bob", + kind = "user" + ) + ), + ), + ) + val store = ChatsStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null + ).create() + try { + store.accept(ChatsStore.Intent.QueryChanged("bob")) + advanceTimeBy(GLOBAL_SEARCH_DEBOUNCE_MS) + runCurrent() + assertEquals(listOf(2L), store.state.searchPeople.map { it.id.value }) + } finally { + store.dispose() + } } - } - private fun searchMessage(id: Int, chat: Long) = Message( + private fun searchMessage( + id: Int, + chat: Long, + ) = Message( id = MessageId(PeerId(chat), id), senderId = null, text = "m$id", @@ -1676,6 +2644,7 @@ class ChatsStoreCacheTest { val releaseBlockedFolder = CompletableDeferred() val events = MutableSharedFlow(extraBufferCapacity = 8) var notifySettingsCalls = 0 + var notifySettingsGate: CompletableDeferred? = null var profileCalls = 0 var chatsGate: CompletableDeferred? = null var contactsPage: Outcome = Outcome.Err("unsupported") @@ -1683,24 +2652,40 @@ class ChatsStoreCacheTest { val contactsQueries = mutableListOf() val globalCalls = mutableListOf() var searchGate: CompletableDeferred? = null - override suspend fun getNotifySettings(peerKind: String, chatId: PeerId): Outcome { + + override suspend fun getNotifySettings( + peerKind: String, + chatId: PeerId, + ): Outcome { notifySettingsCalls++ + notifySettingsGate?.await() return if (failNotifySettings) Outcome.Err("offline") else Outcome.Ok(defaults) } + override suspend fun connect() = Outcome.Ok(Unit) + override suspend fun sendAuthCode(phone: String) = unused() - override suspend fun signIn(phone: String, phoneCodeHash: String, code: String) = unused() + + override suspend fun signIn( + phone: String, + phoneCodeHash: String, + code: String, + ) = unused() + override suspend fun checkPassword(password: String) = unused() + var getChatsCalls = 0 + override suspend fun getChats(): Outcome> { getChatsCalls++ chatsGate?.await() return if (chatResponses.isEmpty()) chats else chatResponses.removeFirst() } + override suspend fun loadMoreChats( offsetDate: Int, offsetId: Int, - offsetPeerId: Long + offsetPeerId: Long, ): Outcome> { mainOffsets += Triple(offsetDate, offsetId, offsetPeerId) mainFlood?.let { flood -> @@ -1709,6 +2694,7 @@ class ChatsStoreCacheTest { } return Outcome.Ok(if (mainPages.isEmpty()) emptyList() else mainPages.removeFirst()) } + override suspend fun loadMoreFolderChats( folderId: Int, offsetDate: Int, @@ -1724,12 +2710,19 @@ class ChatsStoreCacheTest { if (folderId != ARCHIVE_FOLDER_WIRE_ID) return Outcome.Err(FOLDER_ID_INVALID) return Outcome.Ok(if (archivePages.isEmpty()) emptyList() else archivePages.removeFirst()) } + override suspend fun getFolders() = Outcome.Ok(emptyList()) + var historyCalls = 0 - override suspend fun getHistory(chatId: PeerId, limit: Int): Outcome> { + + override suspend fun getHistory( + chatId: PeerId, + limit: Int, + ): Outcome> { historyCalls++ return Outcome.Ok(emptyList()) } + override suspend fun getHistoryPage( chatId: PeerId, limit: Int, @@ -1737,13 +2730,22 @@ class ChatsStoreCacheTest { offsetDate: Int, addOffset: Int, ) = Outcome.Ok(emptyList()) - override suspend fun searchMessages(chatId: PeerId, query: String, limit: Int) = - Outcome.Ok(emptyList()) - override suspend fun contactsSearch(query: String, limit: Int): Outcome { + + override suspend fun searchMessages( + chatId: PeerId, + query: String, + limit: Int, + ) = Outcome.Ok(emptyList()) + + override suspend fun contactsSearch( + query: String, + limit: Int, + ): Outcome { contactsQueries += query searchGate?.await() return contactsPage } + override suspend fun searchGlobal( query: String, offsetRate: Int, @@ -1760,7 +2762,12 @@ class ChatsStoreCacheTest { globalPages.removeFirst() } } - override suspend fun getPinnedMessages(chatId: PeerId, limit: Int) = Outcome.Ok(emptyList()) + + override suspend fun getPinnedMessages( + chatId: PeerId, + limit: Int, + ) = Outcome.Ok(emptyList()) + override suspend fun sendText( chatId: PeerId, text: String, @@ -1770,6 +2777,7 @@ class ChatsStoreCacheTest { webpageUrl: String?, randomId: Long, ) = unused() + override suspend fun sendPhoto( chatId: PeerId, path: String, @@ -1778,30 +2786,68 @@ class ChatsStoreCacheTest { topMsgId: Int, entitiesJson: String?, ) = unused() - override suspend fun editText(chatId: PeerId, messageId: Int, text: String, entitiesJson: String?) = - unused() - override suspend fun deleteMessage(chatId: PeerId, messageId: Int, revoke: Boolean) = Outcome.Ok(Unit) - override suspend fun forwardMessage(fromChatId: PeerId, messageId: Int, toChatId: PeerId) = - Outcome.Ok(emptyList()) - override suspend fun readHistory(chatId: PeerId, maxId: Int): Outcome { + + override suspend fun editText( + chatId: PeerId, + messageId: Int, + text: String, + entitiesJson: String?, + ) = unused() + + override suspend fun deleteMessage( + chatId: PeerId, + messageId: Int, + revoke: Boolean, + ) = Outcome.Ok(Unit) + + override suspend fun forwardMessage( + fromChatId: PeerId, + messageId: Int, + toChatId: PeerId, + ) = Outcome.Ok(emptyList()) + + override suspend fun readHistory( + chatId: PeerId, + maxId: Int, + ): Outcome { reads += chatId to maxId return Outcome.Ok(Unit) } - override suspend fun setTyping(chatId: PeerId, typing: Boolean) = Outcome.Ok(Unit) + + override suspend fun setTyping( + chatId: PeerId, + typing: Boolean, + ) = Outcome.Ok(Unit) + override suspend fun getProfile(peerId: PeerId): Outcome { profileCalls++ return profile } - override suspend fun downloadMessageMedia(chatId: PeerId, messageId: Int, destPath: String) = - unused() - override suspend fun downloadMessageThumb(chatId: PeerId, messageId: Int, destPath: String) = - unused() + + override suspend fun downloadMessageMedia( + chatId: PeerId, + messageId: Int, + destPath: String, + ) = unused() + + override suspend fun downloadMessageThumb( + chatId: PeerId, + messageId: Int, + destPath: String, + ) = unused() + override suspend fun getUpdatesState() = Outcome.Ok(UpdatesCursor(0, 0, 0, 0)) + override fun updates(): Flow = events + override fun sessionLost(): Flow = emptyFlow() + override fun libraryVersion() = "test" + override suspend fun logout() = Outcome.Ok(Unit) + override fun close() = Unit + private fun unused(): Outcome = Outcome.Err("unused") } } diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt index c1ac723f7..77c7aaf58 100644 --- a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt @@ -39,8 +39,19 @@ class HistoryLoadingDeviceTest { private val events = MutableSharedFlow(extraBufferCapacity = 32) private var offline = false private var historyCalls = 0 + private var historyResponses = 0 + @Volatile + private var readHistoryCalls = 0 private var olderCalls = 0 private var newerCalls = 0 + private var openingAt = 0L + + private fun timing(operation: String, startedAt: Long) { + android.util.Log.i( + "LoadingUiTiming", + "operation=$operation ms=${android.os.SystemClock.elapsedRealtime() - startedAt}" + ) + } private fun message(id: Int) = Message( id = MessageId(peer, id), senderId = peer, text = "Fixture $id", date = 1700000000L + id, outgoing = false, @@ -72,6 +83,7 @@ class HistoryLoadingDeviceTest { val failure = offline scope.launch { delay(180) + if (method.name == "getHistory") historyResponses++ continuation.resume( if (failure) Outcome.Err("offline") else Outcome.Ok( rows.map( @@ -83,6 +95,10 @@ class HistoryLoadingDeviceTest { COROUTINE_SUSPENDED } + "readHistory" -> { + readHistoryCalls++ + Outcome.Ok(Unit) + } "getPinnedMessages", "getSavedGifs" -> Outcome.Ok(emptyList()) "getProfile", "getNotifySettings", "getForumTopics", "getForumTopicsById" -> Outcome.Err( "unsupported fixture metadata" @@ -188,6 +204,7 @@ class HistoryLoadingDeviceTest { override suspend fun messages(chatId: PeerId, limit: Int) = if (cached) (240 downTo 201).map(::message) else emptyList() } + openingAt = android.os.SystemClock.elapsedRealtime() compose.runOnUiThread { compose.activity.window.addOnFrameMetricsAvailableListener( frameListener, @@ -341,18 +358,25 @@ class HistoryLoadingDeviceTest { fun uncachedOpeningPagingAndIncomingMessagesRender() = scenario(false, false) { component -> compose.waitUntil(15000) { !component.state.value.loading && component.state.value.messages.isNotEmpty() } compose.onNodeWithText("Fixture 240", substring = true).assertIsDisplayed() + timing("history_initial_render", openingAt) + val updateAt = android.os.SystemClock.elapsedRealtime() compose.runOnUiThread { events.tryEmit(MtprotoUpdate.NewMessage(message(241))) } compose.waitUntil(10000) { component.state.value.messages.any { it.id.id == 241 } } compose.onNodeWithText("Fixture 241", substring = true).assertIsDisplayed() + timing("incoming_fixture_render", updateAt) + val olderAt = android.os.SystemClock.elapsedRealtime() compose.runOnUiThread { component.onLoadOlder() } - compose.waitUntil(10000) { component.state.value.messages.size > 40 } + compose.waitUntil(10000) { olderCalls > 0 && !component.state.value.loadingOlder && component.state.value.messages.any { it.id.id == 150 } } compose.onAllNodes(hasScrollAction()).onFirst().performTouchInput { swipeDown() } compose.onAllNodes(hasScrollAction()).onFirst() - .performScrollToNode(hasText("Fixture 210", substring = true)) - compose.onNodeWithText("Fixture 210", substring = true).assertIsDisplayed() + .performScrollToNode(hasText("Fixture 150", substring = true)) + compose.onNodeWithText("Fixture 150", substring = true).assertIsDisplayed() + timing("older_fixture_render", olderAt) compose.runOnUiThread { events.tryEmit(MtprotoUpdate.NewMessage(message(242))) } compose.waitUntil(10000) { component.state.value.messages.any { it.id.id == 242 } } - compose.onNodeWithText("Fixture 210", substring = true).assertIsDisplayed() + compose.onNodeWithText("Fixture 150", substring = true).assertIsDisplayed() + val ids = component.state.value.messages.map { it.id.id }.sorted() + assertEquals((ids.first()..242).toList(), ids) assertEquals( component.state.value.messages.size, component.state.value.messages.map { it.id }.distinct().size @@ -370,12 +394,27 @@ class HistoryLoadingDeviceTest { fun cachedUnreadOpeningSurvivesOfflineAndRetry() = scenario(true, true) { component -> compose.waitUntil(15000) { component.state.value.messages.isNotEmpty() && !component.state.value.loading } compose.waitUntil(10000) { component.state.value.anchorMessageId == 221 } + compose.waitUntil(10000) { + compose.onNodeWithText("Fixture 221", substring = true).isDisplayed() + } + compose.onNodeWithText("Fixture 221", substring = true).assertIsDisplayed() + timing("history_cached_unread_render", openingAt) + compose.waitUntil(10000) { readHistoryCalls > 0 } + compose.waitForIdle() + assertTrue(component.state.value.error == null) compose.onNodeWithText("Fixture 221", substring = true).assertIsDisplayed() compose.runOnUiThread { offline = true; component.onRefresh() } - compose.waitUntil(10000) { historyCalls >= 2 && !component.state.value.loading } + compose.waitUntil(10000) { + historyResponses >= 2 && !component.state.value.loading && component.state.value.error != null + } assertTrue(component.state.value.messages.isNotEmpty()) + val refreshAt = android.os.SystemClock.elapsedRealtime() compose.runOnUiThread { offline = false; component.onRefresh() } - compose.waitUntil(10000) { historyCalls >= 3 && !component.state.value.loading } + compose.waitUntil(10000) { + historyResponses >= 3 && !component.state.value.loading && component.state.value.error == null + } assertTrue(component.state.value.messages.isNotEmpty()) + compose.onNodeWithText("Fixture 221", substring = true).assertIsDisplayed() + timing("history_retry_render", refreshAt) } } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogComponent.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogComponent.kt index 1cfe73151..f779d8dd3 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogComponent.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogComponent.kt @@ -79,15 +79,15 @@ class DialogComponent( private val onResumeLiveLocation: (suspend (MessageId) -> Boolean)? = null, private val onStopLiveLocation: (suspend (MessageId) -> Boolean)? = null, val liveLocationErrors: Flow> = flowOf( - emptyMap() + emptyMap(), ), val liveLocationFailure: Flow = flowOf( - null + null, ), ) : ComponentContext by componentContext { - private val liveLocationEpoch = warmup?.outgoingAccountEpoch() internal val liveLocationControlKey get() = "$liveLocationEpoch:$stateKey" + private fun liveLocationAccountCurrent() = liveLocationEpoch == null || warmup?.outgoingAccountIsCurrent(liveLocationEpoch) == true @@ -97,22 +97,20 @@ class DialogComponent( ?: flowOf(emptyList()) suspend fun resumeLiveLocation(messageId: Int): Boolean = - messageId > 0 && liveLocationAccountCurrent() && - onResumeLiveLocation?.invoke( - MessageId( - chatId, - messageId - ) - ) == true + messageId > 0 && liveLocationAccountCurrent() && onResumeLiveLocation?.invoke( + MessageId( + chatId, + messageId, + ), + ) == true suspend fun stopLiveLocation(messageId: Int): Boolean = - messageId > 0 && liveLocationAccountCurrent() && - onStopLiveLocation?.invoke( - MessageId( - chatId, - messageId - ) - ) == true + messageId > 0 && liveLocationAccountCurrent() && onStopLiveLocation?.invoke( + MessageId( + chatId, + messageId, + ), + ) == true private val mutableLiveBusy = MutableStateFlow>(emptySet()) val liveLocationBusy: StateFlow> = mutableLiveBusy @@ -120,9 +118,13 @@ class DialogComponent( val liveLocationActionFailed: StateFlow> = mutableLiveFailed fun onResumeLiveLocation(messageId: Int) = changeLiveLocation(messageId, true) + fun onStopLiveLocation(messageId: Int) = changeLiveLocation(messageId, false) - private fun changeLiveLocation(messageId: Int, resume: Boolean) { + private fun changeLiveLocation( + messageId: Int, + resume: Boolean, + ) { if (messageId <= 0 || !liveLocationControlsAvailable || !liveLocationAccountCurrent()) return while (true) { val current = mutableLiveBusy.value @@ -135,21 +137,23 @@ class DialogComponent( mutableLiveFailed.update { it - messageId } val changed = if (resume) resumeLiveLocation(messageId) else stopLiveLocation(messageId) - if ((epoch == null || warmup?.outgoingAccountIsCurrent(epoch) == true) && !changed) + if ((epoch == null || warmup?.outgoingAccountIsCurrent(epoch) == true) && !changed) { mutableLiveFailed.update { it + messageId } + } } catch (cancelled: kotlinx.coroutines.CancellationException) { throw cancelled } catch (_: Exception) { - if (epoch == null || warmup?.outgoingAccountIsCurrent(epoch) == true) + if (epoch == null || warmup?.outgoingAccountIsCurrent(epoch) == true) { mutableLiveFailed.update { it + messageId } + } } finally { mutableLiveBusy.update { it - messageId } } } } - val hasScheduledMessages = warmup?.observeScheduledMessages(chatId)?.map { it.isNotEmpty() } - ?: flowOf(false) + val hasScheduledMessages = + warmup?.observeScheduledMessages(chatId)?.map { it.isNotEmpty() } ?: flowOf(false) /** Dialog identity for saveable state. */ val stateKey: String get() = dialogStateKey(chatId.value, threadTopMsgId, jumpToMessageId) @@ -169,16 +173,21 @@ class DialogComponent( init { warmup?.setOpenChat(chatId.value) - if (warmup != null) preloadScope.launch { - val epoch = warmup.outgoingAccountEpoch() - try { - val revision = warmup.scheduledQueueRevision(chatId, epoch) - val result = client.getScheduledHistory(chatId) - if (result is org.monogram.core.common.Outcome.Ok) - warmup.replaceScheduledMessagesIfCurrent(chatId, revision, result.value, epoch) - } catch (cancelled: kotlinx.coroutines.CancellationException) { - throw cancelled - } catch (_: Exception) { + if (warmup != null) { + preloadScope.launch { + val epoch = warmup.outgoingAccountEpoch() + try { + val revision = warmup.scheduledQueueRevision(chatId, epoch) + val result = client.getScheduledHistory(chatId) + if (result is org.monogram.core.common.Outcome.Ok) { + warmup.replaceScheduledMessagesIfCurrent( + chatId, revision, result.value, epoch + ) + } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + } } } lifecycle.doOnStart { client.setDialogForeground(true) } @@ -192,8 +201,9 @@ class DialogComponent( } } - class MarkdownEditorChild(componentContext: ComponentContext) : - ComponentContext by componentContext + class MarkdownEditorChild( + componentContext: ComponentContext, + ) : ComponentContext by componentContext private val editorNavigation = SlotNavigation() val editorSlot: Value> = childSlot( @@ -206,12 +216,13 @@ class DialogComponent( val markdownAvailable: Boolean get() = isPremium() fun openMarkdownEditor() = editorNavigation.activate(Unit) + fun closeMarkdownEditor() = editorNavigation.dismiss() internal suspend fun prepareEditorPayload( text: String, entities: List, - markdown: Boolean + markdown: Boolean, ): org.monogram.core.models.StyledText { val current = state.value val mentions = current.draftMentions.map { @@ -240,6 +251,7 @@ class DialogComponent( markup = markup, isPremium = isPremium, initialChecklistEdit = restoredChecklistEdit, + memory = DialogMemory.forClient(client), ).create() } @@ -248,7 +260,7 @@ class DialogComponent( init { stateKeeper.register( "checklist-edit", - String.serializer() + String.serializer(), ) { ChecklistEditState.from(store.state)?.encode() } lifecycle.doOnDestroy { stateKeeper.unregister("checklist-edit") } } @@ -316,9 +328,13 @@ class DialogComponent( } fun onRefresh() = store.accept(DialogStore.Intent.Refresh) + fun onRefreshPresence() = store.accept(DialogStore.Intent.RefreshPresence) + fun onLoadOlder() = store.accept(DialogStore.Intent.LoadOlder) + fun onLoadNewer() = store.accept(DialogStore.Intent.LoadNewer) + fun onDraftFormatting(enabled: Boolean) = store.accept(DialogStore.Intent.SetDraftFormatting(enabled)) @@ -326,20 +342,35 @@ class DialogComponent( store.accept(DialogStore.Intent.SetDraftEntities(entities)) fun onDraftChanged(value: String) = store.accept(DialogStore.Intent.DraftChanged(value)) + fun onFixLinkPreview() = store.accept(DialogStore.Intent.FixLinkPreview) + fun onDismissLinkPreview() = store.accept(DialogStore.Intent.DismissLinkPreview) + fun onRestoreLinkPreview() = store.accept(DialogStore.Intent.RestoreLinkPreview) + fun onSelectLinkPreview(url: String) = store.accept(DialogStore.Intent.SelectLinkPreview(url)) + fun onAttachPhoto(path: String) = store.accept(DialogStore.Intent.AttachPhoto(path)) + fun onAttachMedia(items: List) = store.accept(DialogStore.Intent.AttachMedia(items)) + fun onAppendMedia(items: List) = store.accept(DialogStore.Intent.AppendMedia(items)) + fun onClearAttach() = store.accept(DialogStore.Intent.ClearAttach) + fun onToggleAttachSheet() = store.accept(DialogStore.Intent.ToggleAttachSheet) + fun onCloseAttachSheet() = store.accept(DialogStore.Intent.CloseAttachSheet) + fun onToggleEmojiPanel() = store.accept(DialogStore.Intent.ToggleEmojiPanel) + fun onSetEmojiTab(tab: String) = store.accept(DialogStore.Intent.SetEmojiTab(tab)) + fun onReloadPickerContent() = store.accept(DialogStore.Intent.ReloadPickerContent) + fun onInsertEmoji(glyph: String) = store.accept(DialogStore.Intent.InsertEmoji(glyph)) + fun onSendUpload( path: String, kind: String, @@ -352,70 +383,105 @@ class DialogComponent( DialogStore.Intent.SendUpload(path, kind, fileName, mimeType, duration, width, height), ) - fun onSendAlbum(items: List) = - store.accept(DialogStore.Intent.SendAlbum(items)) + fun onSendAlbum(items: List) = store.accept(DialogStore.Intent.SendAlbum(items)) - fun onOpenStickerPack(setId: Long, accessHash: Long) = - store.accept(DialogStore.Intent.OpenStickerPack(setId, accessHash)) + fun onOpenStickerPack( + setId: Long, + accessHash: Long, + ) = store.accept(DialogStore.Intent.OpenStickerPack(setId, accessHash)) - fun onReply(message: Message, focusComposer: Boolean = false) = - store.accept(DialogStore.Intent.ReplyTo(message, focusComposer = focusComposer)) + fun onReply( + message: Message, + focusComposer: Boolean = false, + ) = store.accept(DialogStore.Intent.ReplyTo(message, focusComposer = focusComposer)) fun onRetryDraftSync() = store.accept(DialogStore.Intent.RetryDraftSync) + fun onDiscardDraft() = store.accept(DialogStore.Intent.DiscardDraft) + fun onClearReply() = store.accept(DialogStore.Intent.ClearReply) - fun onEdit(message: Message) = - store.accept(DialogStore.Intent.Edit(message)) + + fun onEdit(message: Message) = store.accept(DialogStore.Intent.Edit(message)) fun onReplaceAttachment( messageId: Int, expectedKey: String, - item: UploadItem - ) = - store.accept(DialogStore.Intent.ReplaceAttachment(messageId, expectedKey, item)) + item: UploadItem, + ) = store.accept(DialogStore.Intent.ReplaceAttachment(messageId, expectedKey, item)) fun onCancelEdit() = store.accept(DialogStore.Intent.CancelEdit) - fun onDelete(messageId: Int, revoke: Boolean) = - store.accept(DialogStore.Intent.Delete(messageId, revoke)) + + fun onDelete( + messageId: Int, + revoke: Boolean, + ) = store.accept(DialogStore.Intent.Delete(messageId, revoke)) fun onForwardPick(message: Message) = onForwardMessages(listOf(message)) + fun onForwardMessages(messages: List) { val chatCanForward = state.value.canForward - val selected = messages - .filter { it.id.chatId == chatId && it.canForwardFrom(chatCanForward) } - .distinctBy { it.id } - .sortedBy { it.id.id } + val selected = + messages.filter { it.id.chatId == chatId && it.canForwardFrom(chatCanForward) } + .distinctBy { it.id }.sortedBy { it.id.id } if (selected.isEmpty()) return - if (onRequestForward != null) onRequestForward.invoke(selected) - else store.accept(DialogStore.Intent.ForwardPick(selected.first())) + if (onRequestForward != null) { + onRequestForward.invoke(selected) + } else { + store.accept(DialogStore.Intent.ForwardPick(selected.first())) + } } fun onClearForward() = store.accept(DialogStore.Intent.ClearForward) + fun onForwardQuery(value: String) = store.accept(DialogStore.Intent.ForwardQuery(value)) + fun onForwardTo(peerId: PeerId) = store.accept(DialogStore.Intent.ForwardTo(peerId)) + fun onClearForwardHint() = store.accept(DialogStore.Intent.ClearForwardHint) - fun onSendWithOptions(text: String?, options: MessageSendOptions) = - store.accept(DialogStore.Intent.SendWithOptions(text, options)) + + fun onSendWithOptions( + text: String?, + options: MessageSendOptions, + ) = store.accept(DialogStore.Intent.SendWithOptions(text, options)) fun onRetryMediaSend(batchId: Long) = store.accept(DialogStore.Intent.RetryMediaSend(batchId)) + fun onRetryTextSend(randomId: Long) = store.accept(DialogStore.Intent.RetryTextSend(randomId)) + fun onSend(text: String? = null) = store.accept(DialogStore.Intent.Send(text)) + fun onReadOnlyBar() = store.accept(DialogStore.Intent.ReadOnlyBar) + fun onRetryFailed() = store.accept(DialogStore.Intent.RetryFailed) + fun onSearch(query: String) = store.accept(DialogStore.Intent.Search(query)) + fun onClearSearch() = store.accept(DialogStore.Intent.ClearSearch) + fun onJumpToDate(epochSeconds: Int) = store.accept(DialogStore.Intent.JumpToDate(epochSeconds)) + fun onSaveScroll(messageId: Int) = store.accept(DialogStore.Intent.SaveScroll(messageId)) + fun onNextPinned() = store.accept(DialogStore.Intent.NextPinned) + fun onOpenPinnedList() = store.accept(DialogStore.Intent.OpenPinnedList) + fun onClosePinnedList() = store.accept(DialogStore.Intent.ClosePinnedList) + fun onJumpToPinned(messageId: Int) = store.accept(DialogStore.Intent.JumpToPinned(messageId)) + fun onJumpToMessage(messageId: Int) = store.accept(DialogStore.Intent.JumpToMessage(messageId)) + fun onJumpUnread() = store.accept(DialogStore.Intent.JumpUnread) + fun onJumpMention() = store.accept(DialogStore.Intent.JumpMention) + fun onJumpUnreadReaction() = store.accept(DialogStore.Intent.JumpUnreadReaction) + fun onJumpLatest() = store.accept(DialogStore.Intent.JumpLatest) + fun onMarkRead() = store.accept(DialogStore.Intent.MarkRead) + fun onLoadReadReceipts(messageId: Int) = store.accept(DialogStore.Intent.LoadReadReceipts(messageId)) @@ -425,7 +491,10 @@ class DialogComponent( fun onLoadPollVoters(messageId: Int) = store.accept(DialogStore.Intent.LoadPollVoters(messageId)) - fun onVisibleNewest(messageId: Int, atLiveEdge: Boolean = false) { + fun onVisibleNewest( + messageId: Int, + atLiveEdge: Boolean = false, + ) { if (lifecycle.state != Lifecycle.State.STARTED && lifecycle.state != Lifecycle.State.RESUMED) return store.accept(DialogStore.Intent.VisibleRead(messageId, atLiveEdge)) } @@ -437,14 +506,19 @@ class DialogComponent( } fun onToggleGifPicker() = store.accept(DialogStore.Intent.ToggleGifPicker) + fun onSendSavedGif(documentId: Long) = store.accept(DialogStore.Intent.SendSavedGif(documentId)) - fun onReact(messageId: Int, emoticon: String, documentId: Long) = - store.accept(DialogStore.Intent.React(messageId, emoticon, documentId)) - fun onOpenComments(message: Message) = - store.accept(DialogStore.Intent.OpenComments(message)) + fun onReact( + messageId: Int, + emoticon: String, + documentId: Long, + ) = store.accept(DialogStore.Intent.React(messageId, emoticon, documentId)) + + fun onOpenComments(message: Message) = store.accept(DialogStore.Intent.OpenComments(message)) fun onOpenedChat() = store.accept(DialogStore.Intent.ClearPendingChat) + fun openPendingChatIfAny() { val id = store.state.pendingChatId ?: return val messageId = store.state.pendingChatMessageId ?: 0 @@ -453,57 +527,77 @@ class DialogComponent( } fun onBack() = onBack.invoke() + fun onOpenScheduledQueue() = onOpenScheduledQueue.invoke() + fun onOpenProfile() = onOpenProfile.invoke(chatId) + fun onOpenPeer(peerId: PeerId) = onOpenProfile.invoke(peerId) + fun onLoadMoreTopics() = store.accept(DialogStore.Intent.LoadMoreTopics) - fun onToggleTopicHidden(topicId: Int, hidden: Boolean) = - store.accept(DialogStore.Intent.ToggleTopicHidden(topicId, hidden)) - fun onRemoveChecklistItem(message: Message, itemId: Int) = - store.accept(DialogStore.Intent.RemoveChecklistItem(message, itemId)) + fun onToggleTopicHidden( + topicId: Int, + hidden: Boolean, + ) = store.accept(DialogStore.Intent.ToggleTopicHidden(topicId, hidden)) + + fun onRemoveChecklistItem( + message: Message, + itemId: Int, + ) = store.accept(DialogStore.Intent.RemoveChecklistItem(message, itemId)) - fun onEditChecklistItem(message: Message, itemId: Int) = - store.accept(DialogStore.Intent.Edit(message, itemId)) + fun onEditChecklistItem( + message: Message, + itemId: Int, + ) = store.accept(DialogStore.Intent.Edit(message, itemId)) - fun onToggleChecklist(messageId: Int, itemId: Int) = - store.accept(DialogStore.Intent.ToggleChecklist(messageId, itemId)) + fun onToggleChecklist( + messageId: Int, + itemId: Int, + ) = store.accept(DialogStore.Intent.ToggleChecklist(messageId, itemId)) fun onClosePoll(messageId: Int) = store.accept(DialogStore.Intent.ClosePoll(messageId)) - fun onVotePoll(messageId: Int, options: List) = - store.accept(DialogStore.Intent.VotePoll(messageId, options)) + fun onVotePoll( + messageId: Int, + options: List, + ) = store.accept(DialogStore.Intent.VotePoll(messageId, options)) fun onLoadContactImport(spec: org.monogram.core.models.ContactSendSpec) = store.accept(LoadContactImport(spec)) - fun onImportContact(spec: org.monogram.core.models.ContactSendSpec, retry: Boolean) = - store.accept(ImportContact(spec, retry)) + fun onImportContact( + spec: org.monogram.core.models.ContactSendSpec, + retry: Boolean, + ) = store.accept(ImportContact(spec, retry)) fun onSendContact( spec: org.monogram.core.models.ContactSendSpec, - options: MessageSendOptions = MessageSendOptions() - ) = - store.accept(DialogStore.Intent.SendContact(spec, options)) + options: MessageSendOptions = MessageSendOptions(), + ) = store.accept(DialogStore.Intent.SendContact(spec, options)) fun onSendChecklist( spec: org.monogram.core.models.ChecklistSendSpec, - options: MessageSendOptions = MessageSendOptions() - ) = - store.accept(DialogStore.Intent.SendChecklist(spec, options)) + options: MessageSendOptions = MessageSendOptions(), + ) = store.accept(DialogStore.Intent.SendChecklist(spec, options)) fun onSendPoll( spec: org.monogram.core.models.PollSendSpec, - options: MessageSendOptions = MessageSendOptions() - ) = - store.accept(DialogStore.Intent.SendPoll(spec, options)) + options: MessageSendOptions = MessageSendOptions(), + ) = store.accept(DialogStore.Intent.SendPoll(spec, options)) - fun onAppendChecklistItems(messageId: Int, firstId: Int, titles: List) = - store.accept(DialogStore.Intent.AppendChecklistItems(messageId, firstId, titles)) + fun onAppendChecklistItems( + messageId: Int, + firstId: Int, + titles: List, + ) = store.accept(DialogStore.Intent.AppendChecklistItems(messageId, firstId, titles)) /** 0 sends a static pin; a positive period sends a live location for that many seconds. */ - fun onSendLocation(latitude: Double, longitude: Double, livePeriodSeconds: Int = 0) = - store.accept(DialogStore.Intent.SendLocation(latitude, longitude, livePeriodSeconds)) + fun onSendLocation( + latitude: Double, + longitude: Double, + livePeriodSeconds: Int = 0, + ) = store.accept(DialogStore.Intent.SendLocation(latitude, longitude, livePeriodSeconds)) fun onBotButton( messageId: Int, @@ -512,28 +606,31 @@ class DialogComponent( ) = store.accept(DialogStore.Intent.BotButton(messageId, button, fromKeyboard)) fun onClearBotNotice() = store.accept(DialogStore.Intent.ClearBotNotice) + fun onDismissBotAlert() = store.accept(DialogStore.Intent.DismissBotAlert) + fun onClearBotUrl() = store.accept(DialogStore.Intent.ClearBotUrl) + fun onClearCopyText() = store.accept(DialogStore.Intent.ClearCopyText) + fun onSendInlineResult(resultId: String) = store.accept(DialogStore.Intent.SendInlineResult(resultId)) - fun onLoadMoreInlineResults() = - store.accept(DialogStore.Intent.LoadMoreInlineResults) + fun onLoadMoreInlineResults() = store.accept(DialogStore.Intent.LoadMoreInlineResults) - fun onRetryInlineResults() = - store.accept(DialogStore.Intent.RetryInlineResults) + fun onRetryInlineResults() = store.accept(DialogStore.Intent.RetryInlineResults) fun onSelectMention(candidate: MentionCandidate) = store.accept(DialogStore.Intent.SelectMention(candidate)) - fun onLoadMoreMentions() = - store.accept(DialogStore.Intent.LoadMoreMentions) + fun onLoadMoreMentions() = store.accept(DialogStore.Intent.LoadMoreMentions) - internal fun instantViewController(url: String, hash: Int): InstantViewController = - instanceKeeper.getOrCreate("iv:$url:$hash") { - InstantViewController(client, url, hash) - } + internal fun instantViewController( + url: String, + hash: Int, + ): InstantViewController = instanceKeeper.getOrCreate("iv:$url:$hash") { + InstantViewController(client, url, hash) + } fun onOpenTopic(topic: ForumTopic) { onOpenChat(chatId, 0, ForumIo.dialogThreadId(topic.id)) diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogNavigation.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogNavigation.kt index d1a140e29..2b52ce40a 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogNavigation.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogNavigation.kt @@ -21,12 +21,11 @@ fun visibleUnreadBadgeCount( unreadCount: Int, readInboxMaxId: Int, hasNewer: Boolean = false, -): Int = - if (unreadDividerIndex(messages, unreadCount, readInboxMaxId, hasNewer) != null) { - unreadCount - } else { - 0 - } +): Int = if (unreadDividerIndex(messages, unreadCount, readInboxMaxId, hasNewer) != null) { + unreadCount +} else { + 0 +} fun unreadDividerIndex( messages: List, @@ -43,7 +42,11 @@ fun unreadDividerIndex( } /** Newest-first: the oldest of the [unreadCount] newest incoming rows. */ -private fun oldestUnreadIndex(messages: List, unreadCount: Int, hasNewer: Boolean): Int? { +private fun oldestUnreadIndex( + messages: List, + unreadCount: Int, + hasNewer: Boolean, +): Int? { if (hasNewer || unreadCount <= 0) return null var remaining = unreadCount var index = -1 @@ -57,7 +60,10 @@ private fun oldestUnreadIndex(messages: List, unreadCount: Int, hasNewe } /** Don't split an album: the divider sits on the newest member. */ -private fun albumHeadIndex(messages: List, index: Int): Int { +private fun albumHeadIndex( + messages: List, + index: Int, +): Int { val group = messages.getOrNull(index)?.groupedId ?: return index var head = index while (head > 0 && messages[head - 1].groupedId == group) head-- @@ -97,7 +103,10 @@ fun unreadAnchorId( * the first unread row at the top edge and the unread messages below it. Returns null once the * anchor has no rows above it, or when the list is already clamped at the newest row. */ -fun liftAnchorTarget(visibleIndices: List, anchorIndex: Int): Int? { +fun liftAnchorTarget( + visibleIndices: List, + anchorIndex: Int, +): Int? { if (visibleIndices.isEmpty()) return null if (visibleIndices.max() <= anchorIndex) return null val bottom = visibleIndices.min() @@ -141,10 +150,15 @@ fun suppressLiveEdgeRead( return newestLoadedId > pending } -fun nextPinnedIndex(current: Int, size: Int): Int = - if (size <= 0) 0 else (current + 1).mod(size) +fun nextPinnedIndex( + current: Int, + size: Int, +): Int = if (size <= 0) 0 else (current + 1).mod(size) -fun pinnedIndexAfterReload(previousMessageId: Int?, messages: List): Int { +fun pinnedIndexAfterReload( + previousMessageId: Int?, + messages: List, +): Int { if (messages.isEmpty()) return 0 if (previousMessageId == null) return 0 val idx = messages.indexOfFirst { it.id.id == previousMessageId } @@ -169,12 +183,17 @@ fun mergeSenderTags( return merged } -internal object SenderTagMemory { +internal object SenderTagMemory : SenderTagCache() + +internal open class SenderTagCache { private val byChat = dialogChatMemory>() fun get(chatId: Long): Map = synchronized(byChat) { byChat[chatId].orEmpty() } - fun put(chatId: Long, tags: Map): Long? = synchronized(byChat) { + fun put( + chatId: Long, + tags: Map, + ): Long? = synchronized(byChat) { if (tags.isEmpty()) { byChat.remove(chatId) return@synchronized null @@ -187,12 +206,17 @@ internal object SenderTagMemory { fun clear() = synchronized(byChat) { byChat.clear() } } -internal object TopicListMemory { +internal object TopicListMemory : TopicListCache() + +internal open class TopicListCache { private val byChat = dialogChatMemory>() fun get(chatId: Long): List = synchronized(byChat) { byChat[chatId].orEmpty() } - fun put(chatId: Long, topics: List): Long? = synchronized(byChat) { + fun put( + chatId: Long, + topics: List, + ): Long? = synchronized(byChat) { if (topics.isEmpty()) { byChat.remove(chatId) return@synchronized null @@ -205,12 +229,18 @@ internal object TopicListMemory { fun clear() = synchronized(byChat) { byChat.clear() } } -internal object PinnedBarMemory { +internal object PinnedBarMemory : PinnedBarCache() + +internal open class PinnedBarCache { private val byChat = dialogChatMemory() fun get(chatId: Long): PinnedBarSnapshot? = synchronized(byChat) { byChat[chatId] } - fun put(chatId: Long, messages: List, index: Int): Long? = synchronized(byChat) { + fun put( + chatId: Long, + messages: List, + index: Int, + ): Long? = synchronized(byChat) { if (messages.isEmpty()) { byChat.remove(chatId) return@synchronized null @@ -338,8 +368,10 @@ private fun Map<*, *>.jsonLong(key: String): Long? = when (val value = this[key] else -> null } -fun shouldAutoscrollToNewest(firstVisibleIndex: Int, firstVisibleOffset: Int): Boolean = - firstVisibleIndex == 0 && firstVisibleOffset < 80 +fun shouldAutoscrollToNewest( + firstVisibleIndex: Int, + firstVisibleOffset: Int, +): Boolean = firstVisibleIndex == 0 && firstVisibleOffset < 80 /** * reverseLayout prepends the newest row at index 0. That shifts the previously @@ -375,8 +407,10 @@ const val HISTORY_PAINT_LIMIT = 40 private const val HISTORY_ID_SLACK_MIN = 8 private const val DATE_JUMP_CACHE_MAX_SKEW_SEC = 2 * 86_400L -fun historyHasMore(pageSize: Int, limit: Int = HISTORY_PAGE_LIMIT): Boolean = - pageSize >= limit +fun historyHasMore( + pageSize: Int, + limit: Int = HISTORY_PAGE_LIMIT, +): Boolean = pageSize >= limit fun paintHistoryWindow( messages: List, @@ -411,8 +445,10 @@ fun shouldPageOlder( return true } -fun isHistoryLiveEdge(firstVisibleIndex: Int, canScrollBackward: Boolean): Boolean = - firstVisibleIndex == 0 || !canScrollBackward +fun isHistoryLiveEdge( + firstVisibleIndex: Int, + canScrollBackward: Boolean, +): Boolean = firstVisibleIndex == 0 || !canScrollBackward fun shouldPageNewer( firstVisibleIndex: Int, @@ -420,31 +456,34 @@ fun shouldPageNewer( loadingNewer: Boolean, ): Boolean = hasNewer && !loadingNewer && firstVisibleIndex <= 3 -fun applyMessageEdit(messages: List, edited: Message): List = - messages.map { current -> - if (current.id != edited.id) { - current - } else { - val reactionOnly = current.text == edited.text && - current.mediaKind == edited.mediaKind && - current.mediaCacheKey == edited.mediaCacheKey && - current.reactionsJson != edited.reactionsJson && - current.editDate == null - edited.copy( - pending = current.pending, - read = current.read, - failed = current.failed, - editDate = if (reactionOnly) null else (edited.editDate ?: current.editDate), - ) - } +fun applyMessageEdit( + messages: List, + edited: Message, +): List = messages.map { current -> + if (current.id != edited.id) { + current + } else { + val reactionOnly = + current.text == edited.text && current.mediaKind == edited.mediaKind && current.mediaCacheKey == edited.mediaCacheKey && current.reactionsJson != edited.reactionsJson && current.editDate == null + edited.copy( + pending = current.pending, + read = current.read, + failed = current.failed, + editDate = if (reactionOnly) null else (edited.editDate ?: current.editDate), + ) } +} -fun removeDeletedMessages(messages: List, ids: Set): List = - messages.filterNot { it.id.id in ids } +fun removeDeletedMessages( + messages: List, + ids: Set, +): List = messages.filterNot { it.id.id in ids } /** True when the target is outside the loaded window and needs a history fetch. */ -fun jumpNeedsFetch(messages: List, targetId: Int): Boolean = - messages.none { it.id.id == targetId } +fun jumpNeedsFetch( + messages: List, + targetId: Int, +): Boolean = messages.none { it.id.id == targetId } fun historyPagingAllowed(searchQuery: String): Boolean = searchQuery.isBlank() @@ -482,16 +521,17 @@ fun mergeOrReplaceMessages( return if (local.isEmpty()) merged else local + merged } -fun mergeLiveEdgeMessages(current: List, incoming: List): List { +fun mergeLiveEdgeMessages( + current: List, + incoming: List, +): List { if (incoming.isEmpty()) return current if (current.isEmpty()) return incoming val minIncoming = incoming.minOf { it.id.id } val maxIncoming = incoming.maxOf { it.id.id } val incomingIds = incoming.mapTo(HashSet()) { it.id.id } val keep = current.filter { message -> - message.id.id < minIncoming || - message.id.id > maxIncoming || - ((message.pending || message.failed || message.id.id <= 0) && message.id.id !in incomingIds) + message.id.id < minIncoming || message.id.id > maxIncoming || ((message.pending || message.failed || message.id.id <= 0) && message.id.id !in incomingIds) } return incoming + keep } @@ -506,7 +546,12 @@ fun typicalOlderIdSlack(window: List): Int { return (median * 8).coerceIn(HISTORY_ID_SLACK_MIN, 50_000) } -private fun cacheRunBreaks(prev: Message, next: Message, slack: Int, newer: Boolean): Boolean { +private fun cacheRunBreaks( + prev: Message, + next: Message, + slack: Int, + newer: Boolean, +): Boolean { val idGap = if (newer) next.id.id - prev.id.id else prev.id.id - next.id.id val dateGap = if (newer) next.date - prev.date else prev.date - next.date return idGap > slack || dateGap > DATE_JUMP_CACHE_MAX_SKEW_SEC @@ -580,19 +625,23 @@ fun dateJumpCacheWindow( return (listOf(newest) + older).take(limit) } -fun historyOrder(messages: List): List = - messages.sortedWith( - compareByDescending { it.pending || it.id.id <= 0 } - .thenByDescending { it.id.id }, - ) +fun historyOrder(messages: List): List = messages.sortedWith( + compareByDescending { it.pending || it.id.id <= 0 }.thenByDescending { it.id.id }, +) /** Newest-first: first row of an album is the newest member. */ -fun isAlbumHead(messages: List, index: Int): Boolean { +fun isAlbumHead( + messages: List, + index: Int, +): Boolean { val group = messages.getOrNull(index)?.groupedId ?: return true return messages.getOrNull(index - 1)?.groupedId != group } -fun albumSlice(messages: List, start: Int): List { +fun albumSlice( + messages: List, + start: Int, +): List { val head = messages.getOrNull(start) ?: return emptyList() val group = head.groupedId ?: return listOf(head) val out = ArrayList() @@ -605,7 +654,10 @@ fun albumSlice(messages: List, start: Int): List { } /** A grouped message shares its rendered row with the album head. */ -fun messageRowIndex(messages: List, messageId: Int): Int { +fun messageRowIndex( + messages: List, + messageId: Int, +): Int { val index = messages.indexOfFirst { it.id.id == messageId } if (index < 0) return -1 return (0..index).count { isAlbumHead(messages, it) } - 1 @@ -627,8 +679,7 @@ private val ALBUM_VISUAL_KINDS = setOf("photo", "video", "gif") fun isVisualAlbum(album: List): Boolean = album.size > 1 && album.any { it.mediaKind in ALBUM_VISUAL_KINDS } -fun isNonVisualAlbum(album: List): Boolean = - album.size > 1 && !isVisualAlbum(album) +fun isNonVisualAlbum(album: List): Boolean = album.size > 1 && !isVisualAlbum(album) /** Newest-first slice -> send order (oldest first) for Telegram mosaic cells. */ fun albumVisualItems(album: List): List { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt index cdf71fdf6..08a9adaa9 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt @@ -44,100 +44,222 @@ import org.monogram.network.bridge.MtprotoClient import kotlin.coroutines.CoroutineContext data class ViewerPageState( - val loaded: Boolean = false, val loading: Boolean = false, val failed: Boolean = false, - val nextOffset: String? = null, val consumedOffsets: Set = emptySet(), + val loaded: Boolean = false, + val loading: Boolean = false, + val failed: Boolean = false, + val nextOffset: String? = null, + val consumedOffsets: Set = emptySet(), ) interface DialogStore : Store { sealed interface Intent { data object Refresh : Intent + data object RefreshPresence : Intent + data object LoadOlder : Intent + data object LoadNewer : Intent /** Drop in-flight history prefetch. Painted messages stay. */ data object Leave : Intent + data object Resume : Intent - data class DraftChanged(val value: String) : Intent - data class SetDraftFormatting(val enabled: Boolean) : Intent - data class SetDraftEntities(val entities: List) : Intent - data class AttachPhoto(val path: String) : Intent + + data class DraftChanged( + val value: String, + ) : Intent + + data class SetDraftFormatting( + val enabled: Boolean, + ) : Intent + + data class SetDraftEntities( + val entities: List, + ) : Intent + + data class AttachPhoto( + val path: String, + ) : Intent + data class ReplaceAttachment( - val messageId: Int, val expectedKey: String, val item: UploadItem + val messageId: Int, + val expectedKey: String, + val item: UploadItem, + ) : Intent + + data class AttachMedia( + val items: List, + ) : Intent + + data class AppendMedia( + val items: List, ) : Intent - data class AttachMedia(val items: List) : Intent - data class AppendMedia(val items: List) : Intent data object ClearAttach : Intent + data object FixLinkPreview : Intent + data object DismissLinkPreview : Intent + data object RestoreLinkPreview : Intent - data class SelectLinkPreview(val url: String) : Intent - data class ReplyTo(val message: Message, val focusComposer: Boolean = false) : Intent + + data class SelectLinkPreview( + val url: String, + ) : Intent + + data class ReplyTo( + val message: Message, + val focusComposer: Boolean = false, + ) : Intent + data object ClearReply : Intent + data object DiscardDraft : Intent + data object RetryDraftSync : Intent - data class Edit(val message: Message, val itemId: Int? = null) : Intent + + data class Edit( + val message: Message, + val itemId: Int? = null, + ) : Intent + data object CancelEdit : Intent - data class Delete(val messageId: Int, val revoke: Boolean) : Intent - data class ForwardPick(val message: Message) : Intent + + data class Delete( + val messageId: Int, + val revoke: Boolean, + ) : Intent + + data class ForwardPick( + val message: Message, + ) : Intent + data object ClearForward : Intent - data class ForwardQuery(val value: String) : Intent - data class ForwardTo(val chatId: PeerId) : Intent + + data class ForwardQuery( + val value: String, + ) : Intent + + data class ForwardTo( + val chatId: PeerId, + ) : Intent + data object ClearForwardHint : Intent - data class LoadContactImport(val spec: ContactSendSpec) : Intent + + data class LoadContactImport( + val spec: ContactSendSpec, + ) : Intent + data class ImportContact( - val spec: ContactSendSpec, val retry: Boolean + val spec: ContactSendSpec, + val retry: Boolean, ) : Intent data class SendContact( - val spec: ContactSendSpec, val options: MessageSendOptions = MessageSendOptions() + val spec: ContactSendSpec, + val options: MessageSendOptions = MessageSendOptions(), ) : Intent data class SendChecklist( val spec: org.monogram.core.models.ChecklistSendSpec, - val options: MessageSendOptions = MessageSendOptions() + val options: MessageSendOptions = MessageSendOptions(), ) : Intent data class SendPoll( val spec: org.monogram.core.models.PollSendSpec, - val options: MessageSendOptions = MessageSendOptions() + val options: MessageSendOptions = MessageSendOptions(), ) : Intent data class SendWithOptions( - val text: String?, val options: MessageSendOptions + val text: String?, + val options: MessageSendOptions, + ) : Intent + + data class RetryMediaSend( + val batchId: Long, + ) : Intent + + data class RetryTextSend( + val randomId: Long, + ) : Intent + + data class Send( + val text: String? = null, ) : Intent - data class RetryMediaSend(val batchId: Long) : Intent - data class RetryTextSend(val randomId: Long) : Intent - data class Send(val text: String? = null) : Intent data object RetryFailed : Intent - data class Search(val query: String) : Intent - data class JumpToDate(val epochSeconds: Int) : Intent + + data class Search( + val query: String, + ) : Intent + + data class JumpToDate( + val epochSeconds: Int, + ) : Intent + data object ClearSearch : Intent - data class SaveScroll(val messageId: Int) : Intent + + data class SaveScroll( + val messageId: Int, + ) : Intent + data object NextPinned : Intent + data object OpenPinnedList : Intent + data object ClosePinnedList : Intent - data class JumpToPinned(val messageId: Int) : Intent - data class JumpToMessage(val messageId: Int) : Intent + + data class JumpToPinned( + val messageId: Int, + ) : Intent + + data class JumpToMessage( + val messageId: Int, + ) : Intent + data object JumpUnread : Intent + data object JumpMention : Intent + data object JumpUnreadReaction : Intent + data object JumpLatest : Intent + data object MarkRead : Intent - data class ReadReceipts(val enabled: Boolean) : Intent + + data class ReadReceipts( + val enabled: Boolean, + ) : Intent /** The user can see messages up to this id in the open history. */ - data class VisibleRead(val messageId: Int, val atLiveEdge: Boolean = false) : Intent - data class VisibleWindow(val messageIds: Set) : Intent + data class VisibleRead( + val messageId: Int, + val atLiveEdge: Boolean = false, + ) : Intent + + data class VisibleWindow( + val messageIds: Set, + ) : Intent + data object ToggleGifPicker : Intent + data object ToggleAttachSheet : Intent + data object CloseAttachSheet : Intent + data object ToggleEmojiPanel : Intent + data object ReloadPickerContent : Intent - data class SetEmojiTab(val tab: String) : Intent - data class InsertEmoji(val glyph: String) : Intent + + data class SetEmojiTab( + val tab: String, + ) : Intent + + data class InsertEmoji( + val glyph: String, + ) : Intent + data class SendUpload( val path: String, val kind: String, @@ -148,34 +270,80 @@ interface DialogStore : Store { val height: Int = 0, ) : Intent - data class SendAlbum(val items: List) : Intent - data class OpenStickerPack(val setId: Long, val accessHash: Long) : Intent + data class SendAlbum( + val items: List, + ) : Intent + + data class OpenStickerPack( + val setId: Long, + val accessHash: Long, + ) : Intent + data object LoadSavedGifs : Intent - data class SendSavedGif(val documentId: Long) : Intent - data class React(val messageId: Int, val emoticon: String = "", val documentId: Long = 0L) : - Intent - data class OpenComments(val message: Message) : Intent + data class SendSavedGif( + val documentId: Long, + ) : Intent + + data class React( + val messageId: Int, + val emoticon: String = "", + val documentId: Long = 0L, + ) : Intent + + data class OpenComments( + val message: Message, + ) : Intent + data object ClearPendingChat : Intent + data object LoadMoreTopics : Intent - data class ToggleTopicHidden(val topicId: Int, val hidden: Boolean) : Intent - data class SendInlineResult(val resultId: String) : Intent + + data class ToggleTopicHidden( + val topicId: Int, + val hidden: Boolean, + ) : Intent + + data class SendInlineResult( + val resultId: String, + ) : Intent + data object LoadMoreInlineResults : Intent + data object RetryInlineResults : Intent - data class SelectMention(val candidate: MentionCandidate) : Intent + + data class SelectMention( + val candidate: MentionCandidate, + ) : Intent + data object LoadMoreMentions : Intent + data class BotButton( val messageId: Int, val button: ReplyButton, val fromKeyboard: Boolean, ) : Intent - data class RemoveChecklistItem(val message: Message, val itemId: Int) : Intent - data class ToggleChecklist(val messageId: Int, val itemId: Int) : Intent + data class RemoveChecklistItem( + val message: Message, + val itemId: Int, + ) : Intent + + data class ToggleChecklist( + val messageId: Int, + val itemId: Int, + ) : Intent /** Raw option bytes from the poll payload. */ - data class ClosePoll(val messageId: Int) : Intent - data class VotePoll(val messageId: Int, val options: List) : Intent + data class ClosePoll( + val messageId: Int, + ) : Intent + + data class VotePoll( + val messageId: Int, + val options: List, + ) : Intent + data class AppendChecklistItems( val messageId: Int, val firstId: Int, @@ -189,13 +357,25 @@ interface DialogStore : Store { ) : Intent data object ClearBotNotice : Intent + data object DismissBotAlert : Intent + data object ClearBotUrl : Intent - data class LoadReadReceipts(val messageId: Int) : Intent - data class LoadReactionUsers(val messageId: Int) : Intent - data class LoadPollVoters(val messageId: Int) : Intent + data class LoadReadReceipts( + val messageId: Int, + ) : Intent + + data class LoadReactionUsers( + val messageId: Int, + ) : Intent + + data class LoadPollVoters( + val messageId: Int, + ) : Intent + data object ClearCopyText : Intent + data object ReadOnlyBar : Intent } @@ -261,7 +441,6 @@ interface DialogStore : Store { val searching: Boolean = false, val loading: Boolean = false, val loadingOlder: Boolean = false, - /** True while [loadingOlder] came from the automatic post-open prefetch. */ val prefetchingOlder: Boolean = false, val loadingNewer: Boolean = false, @@ -287,7 +466,6 @@ interface DialogStore : Store { val photoCacheKey: String? = null, val anchorMessageId: Int? = null, val anchorRequestId: String? = null, - /** The anchor is the first unread row: show it at the top edge, like Telegram does. */ val anchorAtTop: Boolean = false, val savedGifs: List = emptyList(), @@ -365,6 +543,7 @@ internal class DialogStoreFactory( private val isPremium: () -> Boolean = { false }, private val markupContext: CoroutineContext = Dispatchers.Default, private val initialChecklistEdit: ChecklistEditState? = null, + private val memory: DialogMemory = DialogMemory.legacy, ) { fun create(): DialogStore { val editing = initialChecklistEdit?.message(chatId, threadTopMsgId) @@ -372,7 +551,7 @@ internal class DialogStoreFactory( // Navigation calls this on the main thread. A blocking catalog read there // freezes the window (ANR) and leaves "Updating" on screen. The executor // hydrates the same catalogs off the main thread. - if (!onAndroidMainThread()) warmPickerMemory(sessionStore) + if (!onAndroidMainThread()) warmPickerMemory(sessionStore, memory) return object : DialogStore, Store by storeFactory.create( name = "DialogStore", @@ -384,24 +563,25 @@ internal class DialogStoreFactory( draftEntities = TextEntities.parse(restored?.entitiesJson), draftFormatting = restored?.markdown == true && isPremium(), loading = true, - pinnedMessages = PinnedBarMemory.get(chatId.value)?.messages.orEmpty(), - pinnedIndex = PinnedBarMemory.get(chatId.value)?.index ?: 0, - senderTags = SenderTagMemory.get(chatId.value), - topics = TopicListMemory.get(chatId.value), + pinnedMessages = memory.pins.get(chatId.value)?.messages.orEmpty(), + pinnedIndex = memory.pins.get(chatId.value)?.index ?: 0, + senderTags = memory.tags.get(chatId.value), + topics = memory.topics.get(chatId.value), threadTopId = threadTopMsgId, isForum = seedIsForum == true, - savedGifs = SavedGifMemory.get().orEmpty(), - savedGifsLoaded = SavedGifMemory.get() != null, - stickerSets = StickerCatalogMemory.get(false)?.sets.orEmpty(), - stickerSetsLoaded = StickerCatalogMemory.get(false) != null, - emojiSets = StickerCatalogMemory.get(true)?.sets.orEmpty(), - emojiSetsLoaded = StickerCatalogMemory.get(true) != null, - loadedStickerPacks = StickerPackMemory.snapshot(), + savedGifs = memory.gifs.get().orEmpty(), + savedGifsLoaded = memory.gifs.get() != null, + stickerSets = memory.catalogs.get(false)?.sets.orEmpty(), + stickerSetsLoaded = memory.catalogs.get(false) != null, + emojiSets = memory.catalogs.get(true)?.sets.orEmpty(), + emojiSetsLoaded = memory.catalogs.get(true) != null, + loadedStickerPacks = memory.packs.snapshot(), ), bootstrapper = SimpleBootstrapper(Unit), executorFactory = { DialogExecutor( client = client, + memory = memory, warmup = warmup, sessionStore = sessionStore, chatId = chatId, diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/PickerCache.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/PickerCache.kt index cc50f19c7..f1408f9b3 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/PickerCache.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/PickerCache.kt @@ -5,17 +5,28 @@ import org.monogram.core.database.SessionMetadataStore import org.monogram.core.models.CompactJson import org.monogram.core.models.SavedGif import org.monogram.core.models.StickerPack +import org.monogram.network.bridge.MtprotoClient +import java.util.WeakHashMap internal data class StickerCatalogSnapshot( val hash: Long, val sets: List, ) -internal object StickerCatalogMemory { - @Volatile private var stickers: StickerCatalogSnapshot? = null - @Volatile private var emojis: StickerCatalogSnapshot? = null - @Volatile private var stickersLive = false - @Volatile private var emojisLive = false +internal object StickerCatalogMemory : StickerCatalogCache() + +internal open class StickerCatalogCache { + @Volatile + private var stickers: StickerCatalogSnapshot? = null + + @Volatile + private var emojis: StickerCatalogSnapshot? = null + + @Volatile + private var stickersLive = false + + @Volatile + private var emojisLive = false @Synchronized fun get(emoji: Boolean): StickerCatalogSnapshot? = if (emoji) emojis else stickers @@ -29,7 +40,10 @@ internal object StickerCatalogMemory { } @Synchronized - fun put(emoji: Boolean, snapshot: StickerCatalogSnapshot) { + fun put( + emoji: Boolean, + snapshot: StickerCatalogSnapshot, + ) { if (emoji) emojis = snapshot else stickers = snapshot } @@ -44,8 +58,10 @@ internal object StickerCatalogMemory { internal const val STICKER_PACK_CAP = 24 -internal object StickerPackMemory { - private val packs = object : java.util.LinkedHashMap(16, 0.75f, true) { +internal object StickerPackMemory : StickerPackCache() + +internal open class StickerPackCache { + private val packs = object : LinkedHashMap(16, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > STICKER_PACK_CAP } @@ -85,9 +101,14 @@ internal object StickerPackMemory { } } -internal object SavedGifMemory { - @Volatile private var gifs: List? = null - @Volatile private var live = false +internal object SavedGifMemory : SavedGifCache() + +internal open class SavedGifCache { + @Volatile + private var gifs: List? = null + + @Volatile + private var live = false @Synchronized fun get(): List? = gifs @@ -112,6 +133,40 @@ internal object SavedGifMemory { } } +internal class DialogMemory( + val catalogs: StickerCatalogCache = StickerCatalogCache(), + val packs: StickerPackCache = StickerPackCache(), + val gifs: SavedGifCache = SavedGifCache(), + val pins: PinnedBarCache = PinnedBarCache(), + val tags: SenderTagCache = SenderTagCache(), + val topics: TopicListCache = TopicListCache(), +) { + companion object { + val legacy = DialogMemory( + StickerCatalogMemory, + StickerPackMemory, + SavedGifMemory, + PinnedBarMemory, + SenderTagMemory, + TopicListMemory + ) + private val accounts = WeakHashMap() + + @Synchronized + fun forClient(client: MtprotoClient): DialogMemory = + accounts.getOrPut(client) { DialogMemory() } + + @Synchronized + fun remove(client: MtprotoClient) { + accounts.remove(client) + } + } +} + +fun clearDialogMemory(client: MtprotoClient) { + DialogMemory.remove(client) +} + internal object PickerDisk { private const val KEY_STICKERS = "picker.stickers" private const val KEY_EMOJIS = "picker.emojis" @@ -134,18 +189,27 @@ internal object PickerDisk { store.writeMeta(if (emoji) KEY_EMOJIS else KEY_STICKERS, encodeCatalog(snapshot)) } - suspend fun readPack(store: SessionMetadataStore, id: Long): StickerPack? { + suspend fun readPack( + store: SessionMetadataStore, + id: Long, + ): StickerPack? { val raw = store.readMeta(KEY_PACK_PREFIX + id) ?: return null return runCatching { (CompactJson.parse(raw) as? Map<*, *>)?.toPack() }.getOrNull() } - suspend fun writePack(store: SessionMetadataStore, pack: StickerPack) { + suspend fun writePack( + store: SessionMetadataStore, + pack: StickerPack, + ) { store.writeMeta(KEY_PACK_PREFIX + pack.id, encodePack(pack)) } - suspend fun deletePack(store: SessionMetadataStore, id: Long) { + suspend fun deletePack( + store: SessionMetadataStore, + id: Long, + ) { store.deleteMeta(KEY_PACK_PREFIX + id) } @@ -154,7 +218,10 @@ internal object PickerDisk { return runCatching { decodeGifs(raw) }.getOrNull() } - suspend fun writeGifs(store: SessionMetadataStore, gifs: List) { + suspend fun writeGifs( + store: SessionMetadataStore, + gifs: List, + ) { store.writeMeta(KEY_GIFS, encodeGifs(gifs)) } @@ -175,10 +242,9 @@ internal object PickerDisk { internal fun encodeGifs(gifs: List): String { val rows = gifs.joinToString(",") { gif -> val thumb = gif.thumbCacheKey?.let { ",\"thumbCacheKey\":" + it.jsonString() } ?: "" - "{" + "\"documentId\":" + gif.documentId + - ",\"cacheKey\":" + gif.cacheKey.jsonString() + thumb + "}" + "{" + "\"documentId\":" + gif.documentId + ",\"cacheKey\":" + gif.cacheKey.jsonString() + thumb + "}" } - return "{" + "\"gifs\":[" + rows + "]}" + return "{\"gifs\":[$rows]}" } internal fun decodeGifs(raw: String): List { @@ -196,14 +262,7 @@ internal object PickerDisk { private fun encodePack(pack: StickerPack): String { val ids = pack.previewDocumentIds.joinToString(",") - return "{" + - "\"id\":" + pack.id + - ",\"title\":" + pack.title.jsonString() + - ",\"shortName\":" + pack.shortName.jsonString() + - ",\"count\":" + pack.count + - ",\"isEmoji\":" + pack.isEmoji + - ",\"accessHash\":" + pack.accessHash + - ",\"previewDocumentIds\":[" + ids + "]}" + return "{" + "\"id\":" + pack.id + ",\"title\":" + pack.title.jsonString() + ",\"shortName\":" + pack.shortName.jsonString() + ",\"count\":" + pack.count + ",\"isEmoji\":" + pack.isEmoji + ",\"accessHash\":" + pack.accessHash + ",\"previewDocumentIds\":[" + ids + "]}" } private fun Map<*, *>.toPack(): StickerPack { @@ -219,36 +278,31 @@ internal object PickerDisk { ) } - private fun Map<*, *>.long(key: String): Long = - (this[key] as? Number)?.toLong() ?: 0L + private fun Map<*, *>.long(key: String): Long = (this[key] as? Number)?.toLong() ?: 0L - private fun Map<*, *>.string(key: String): String = - this[key] as? String ?: "" + private fun Map<*, *>.string(key: String): String = this[key] as? String ?: "" - private fun String.jsonString(): String = - "\"" + CompactJson.escape(this) + "\"" + private fun String.jsonString(): String = "\"" + CompactJson.escape(this) + "\"" } /** Fill process memory from disk so the first picker frame can reuse a snapshot. */ -internal fun warmPickerMemory(sessionStore: SessionMetadataStore?) { +internal fun warmPickerMemory( + sessionStore: SessionMetadataStore?, + memory: DialogMemory = DialogMemory.legacy, +) { if (sessionStore == null) return - if ( - StickerCatalogMemory.get(false) != null && - StickerCatalogMemory.get(true) != null && - SavedGifMemory.get() != null - ) { + if (memory.catalogs.get(false) != null && memory.catalogs.get(true) != null && memory.gifs.get() != null) { return } runBlocking { - if (StickerCatalogMemory.get(false) == null) { - PickerDisk.readCatalog(sessionStore, false)?.let { StickerCatalogMemory.put(false, it) } + if (memory.catalogs.get(false) == null) { + PickerDisk.readCatalog(sessionStore, false)?.let { memory.catalogs.put(false, it) } } - if (StickerCatalogMemory.get(true) == null) { - PickerDisk.readCatalog(sessionStore, true)?.let { StickerCatalogMemory.put(true, it) } + if (memory.catalogs.get(true) == null) { + PickerDisk.readCatalog(sessionStore, true)?.let { memory.catalogs.put(true, it) } } - if (SavedGifMemory.get() == null) { - PickerDisk.readGifs(sessionStore)?.let { SavedGifMemory.put(it) } + if (memory.gifs.get() == null) { + PickerDisk.readGifs(sessionStore)?.let { memory.gifs.put(it) } } } } - diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/PickerMediaPreloader.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/PickerMediaPreloader.kt index 5ba0cb0d0..d2e853140 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/PickerMediaPreloader.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/PickerMediaPreloader.kt @@ -2,7 +2,10 @@ package org.monogram.feature.dialog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.monogram.core.common.Outcome +import org.monogram.core.common.telegram.TelegramError import org.monogram.core.models.PeerId import org.monogram.network.http.MediaConsumer import org.monogram.network.http.MediaRepository @@ -73,22 +76,37 @@ class PickerMediaPreloader( consumer: MediaConsumer?, ) { val repo = repository ?: return - when { - task.fetch == PickerMediaPreload.Fetch.Thumb || task.item.gif -> repo.ensureIndexedMedia( - peerId = PeerId(task.item.documentId), - messageId = 0, - cacheKey = task.cacheKey, - thumb = task.fetch == PickerMediaPreload.Fetch.Thumb, - priority = task.priority, - mediaKind = if (task.item.gif) "gif" else null, - consumer = consumer, - ) + // Disk catalogs can expose document ids before their native locations are reindexed. + repeat(4) { attempt -> + val result = when { + task.fetch == PickerMediaPreload.Fetch.Thumb || task.item.gif -> { + repo.ensureIndexedMedia( + peerId = PeerId(task.item.documentId), + messageId = 0, + cacheKey = task.cacheKey, + thumb = task.fetch == PickerMediaPreload.Fetch.Thumb, + priority = task.priority, + mediaKind = if (task.item.gif) "gif" else null, + consumer = consumer, + ) + } - else -> repo.ensureCustomEmoji( - task.item.documentId, - priority = task.priority, - consumer = consumer, - ) + else -> { + repo.ensureCustomEmoji( + task.item.documentId, + priority = task.priority, + consumer = consumer, + ) + } + } + if (result is Outcome.Ok || attempt == 3) return + if (result is Outcome.Err) { + when (result.telegramError.kind) { + TelegramError.Kind.Flood, TelegramError.Kind.Auth, TelegramError.Kind.Session -> return + else -> Unit + } + } + delay(1_000L shl attempt) } } } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt index d87122c41..186ef3171 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt @@ -1,6 +1,5 @@ package org.monogram.feature.dialog.store - import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -8,6 +7,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.withContext import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome @@ -16,6 +16,7 @@ import org.monogram.core.database.OfflineWarmup import org.monogram.core.database.SessionMetadataStore import org.monogram.core.markup.MarkupParser import org.monogram.core.models.Chat +import org.monogram.core.models.CloudDraft import org.monogram.core.models.ForumIo import org.monogram.core.models.LastSeen import org.monogram.core.models.Message @@ -30,13 +31,11 @@ import org.monogram.core.models.preferredPeerTitle import org.monogram.core.models.replaceComposedDialogSeed import org.monogram.core.ui.components.PeerPresence import org.monogram.feature.dialog.ComposerPanels +import org.monogram.feature.dialog.DialogMemory import org.monogram.feature.dialog.DialogStore import org.monogram.feature.dialog.HISTORY_CACHE_LIMIT import org.monogram.feature.dialog.HISTORY_FIRST_LIMIT -import org.monogram.feature.dialog.PinnedBarMemory import org.monogram.feature.dialog.ReadOnlyBarAction -import org.monogram.feature.dialog.SavedGifMemory -import org.monogram.feature.dialog.SenderTagMemory import org.monogram.feature.dialog.applyMessageEdit import org.monogram.feature.dialog.contiguousHistoryFromNewest import org.monogram.feature.dialog.encodeSenderTags @@ -64,12 +63,13 @@ private fun presenceLabelChanged( return LastSeen.resolve(previousStatus, previousAt, now) != LastSeen.resolve( nextStatus, nextAt, - now + now, ) } internal class DialogExecutor( internal val client: MtprotoClient, + internal val memory: DialogMemory = DialogMemory.legacy, internal val warmup: OfflineWarmup?, internal val sessionStore: SessionMetadataStore?, internal val chatId: PeerId, @@ -105,7 +105,7 @@ internal class DialogExecutor( internal var confirmingInlineUser: String? = null internal var linkPreviewJob: Job? = null internal val loadingStickerPacks = mutableSetOf() - internal val stickerPackRequests = kotlinx.coroutines.sync.Semaphore(2) + internal val stickerPackRequests = Semaphore(2) internal val inlineBots = HashMap() internal var lastTypingSent: Boolean? = null internal var historyGen: Int = 0 @@ -129,7 +129,7 @@ internal class DialogExecutor( internal var forumUnknown: Boolean = seedIsForum == null internal var pendingJump = jumpToMessageId internal var inFlightSends = 0 - internal var composerCloudDraft = org.monogram.core.models.CloudDraft(chatId, threadTopMsgId) + internal var composerCloudDraft = CloudDraft(chatId, threadTopMsgId) internal var composerMarkdown = false internal var draftWritesPending = 0 internal var draftSavedRevision = 0L @@ -147,55 +147,72 @@ internal class DialogExecutor( internal val sendSeq = AtomicInteger(0) internal fun emit(msg: Msg) = dispatch(msg) + internal fun snapshot(): DialogStore.State = state() + internal val work get() = scope override fun executeAction(action: Unit) { observeComposerDraft() warmup?.observeOutgoingTextSends()?.onEach { rows -> - emit(Msg.OutgoingTextSends(rows.filter { - it.message.chatId == chatId.value && (threadTopMsgId <= 0 || it.message.replyToTopId == threadTopMsgId) - })) + emit( + Msg.OutgoingTextSends( + rows.filter { + it.message.chatId == chatId.value && (threadTopMsgId <= 0 || it.message.replyToTopId == threadTopMsgId) + }, + ), + ) }?.launchIn(work) warmup?.observeOutgoingMediaSends()?.onEach { rows -> - emit(Msg.OutgoingMediaSends(rows.filter { - it.batch.chatId == chatId.value && (threadTopMsgId <= 0 || it.items.any { item -> item.message.replyToTopId == threadTopMsgId }) - })) + emit( + Msg.OutgoingMediaSends( + rows.filter { + it.batch.chatId == chatId.value && (threadTopMsgId <= 0 || it.items.any { item -> item.message.replyToTopId == threadTopMsgId }) + }, + ), + ) }?.launchIn(work) warmup?.outgoingSendSync?.state?.onEach { emit(Msg.OutgoingSendSync(it)) }?.launchIn(work) client.updates().onEach { update -> when (update) { - is MtprotoUpdate.PollChanged -> refreshPoll(update.pollId) + is MtprotoUpdate.PollChanged -> { + refreshPoll(update.pollId) + } + is MtprotoUpdate.OutgoingMessageAcknowledged -> { val result = update.result if (result is OutgoingMessageResult.Sent && result.message.id.chatId == chatId && state().searchQuery.isBlank() && (threadTopMsgId <= 0 || inCommentThread( - result.message + result.message, )) ) { absorbIncoming( result.message.copy(randomId = update.randomId), - matchPendingByRandomIdOnly = true + matchPendingByRandomIdOnly = true, ) } } - is MtprotoUpdate.NewMessage -> if (update.message.id.chatId == chatId) { - if (state().searchQuery.isNotBlank()) return@onEach - if (threadTopMsgId > 0 && !inCommentThread(update.message)) return@onEach - absorbIncoming(update.message) + is MtprotoUpdate.NewMessage -> { + if (update.message.id.chatId == chatId) { + if (state().searchQuery.isNotBlank()) return@onEach + if (threadTopMsgId > 0 && !inCommentThread(update.message)) return@onEach + absorbIncoming(update.message) + } } - is MtprotoUpdate.MessageEdited -> if (update.message.id.chatId == chatId) { - val existing = - state().messages.firstOrNull { it.id.id == update.message.id.id } - ?: state().pinnedMessages.firstOrNull { it.id.id == update.message.id.id } - val next = applyMessageEdit( - listOf(existing ?: update.message), - update.message, - ).first() - if (existing != next) { - dispatch(Msg.MessageEdited(update.message)) - rememberPinned() + is MtprotoUpdate.MessageEdited -> { + if (update.message.id.chatId == chatId) { + val existing = + state().messages.firstOrNull { it.id.id == update.message.id.id } + ?: state().pinnedMessages.firstOrNull { it.id.id == update.message.id.id } + val next = applyMessageEdit( + listOf(existing ?: update.message), + update.message, + ).first() + if (existing != next) { + dispatch(Msg.MessageEdited(update.message)) + rememberPinned() + } } } @@ -205,36 +222,46 @@ internal class DialogExecutor( if (deleted.isNotEmpty()) { dispatch(Msg.MessagesDeleted(deleted)) rememberPinned() - scope.launch { - warmup?.deleteMessages(chatId, deleted) + if (!update.persisted) { + scope.launch { + warmup?.deleteMessages(chatId, deleted) + } } } } } - is MtprotoUpdate.PeerTyping -> if (update.chatId == chatId) { - applyTyping(update.userId, update.typing, update.action) + is MtprotoUpdate.PeerTyping -> { + if (update.chatId == chatId) { + applyTyping(update.userId, update.typing, update.action) + } } - is MtprotoUpdate.UnreadMentions, is MtprotoUpdate.UnreadReactions, is MtprotoUpdate.UnreadMentionsDelta, is MtprotoUpdate.UnreadReactionsDelta -> handleUnreadCounterUpdate( - update - ) + is MtprotoUpdate.UnreadMentions, is MtprotoUpdate.UnreadReactions, is MtprotoUpdate.UnreadMentionsDelta, is MtprotoUpdate.UnreadReactionsDelta -> { + handleUnreadCounterUpdate( + update, + ) + } - is MtprotoUpdate.ReadInbox -> if (update.chatId == chatId) { - val current = state() - if (threadTopMsgId > 0 || update.maxId < current.readInboxMaxId) return@onEach - if (current.readInboxMaxId != update.maxId) { - dispatch(Msg.ReadInbox(update.maxId)) - } - val unread = update.stillUnread.coerceAtLeast(0) - if (current.unreadCount != unread) { - dispatch(Msg.UnreadCount(unread)) + is MtprotoUpdate.ReadInbox -> { + if (update.chatId == chatId) { + val current = state() + if (threadTopMsgId > 0 || update.maxId < current.readInboxMaxId) return@onEach + if (current.readInboxMaxId != update.maxId) { + dispatch(Msg.ReadInbox(update.maxId)) + } + val unread = update.stillUnread.coerceAtLeast(0) + if (current.unreadCount != unread) { + dispatch(Msg.UnreadCount(unread)) + } } } - is MtprotoUpdate.ReadOutbox -> if (update.chatId == chatId) { - if (state().readOutboxMaxId != update.maxId) { - dispatch(Msg.ReadOutbox(update.maxId)) + is MtprotoUpdate.ReadOutbox -> { + if (update.chatId == chatId) { + if (state().readOutboxMaxId != update.maxId) { + dispatch(Msg.ReadOutbox(update.maxId)) + } } } @@ -262,37 +289,39 @@ internal class DialogExecutor( } } - is MtprotoUpdate.PeerStatus -> if (update.userId == chatId) { - val current = state() - if (!LastSeen.affectsUi( - current.peerStatus, - current.peerStatusAt, - update.status, - update.statusAt, - ) - ) { - return@onEach - } - PeerPresence.record( - update.userId.value, - update.status, - update.statusAt, - ) - if (presenceLabelChanged( - current.peerStatus, - current.peerStatusAt, - update.status, - update.statusAt, - ) - ) { - dispatch(Msg.PeerStatus(update.status, update.statusAt)) - } - scope.launch { - sessionStore?.updatePeerStatus( + is MtprotoUpdate.PeerStatus -> { + if (update.userId == chatId) { + val current = state() + if (!LastSeen.affectsUi( + current.peerStatus, + current.peerStatusAt, + update.status, + update.statusAt, + ) + ) { + return@onEach + } + PeerPresence.record( update.userId.value, update.status, update.statusAt, ) + if (presenceLabelChanged( + current.peerStatus, + current.peerStatusAt, + update.status, + update.statusAt, + ) + ) { + dispatch(Msg.PeerStatus(update.status, update.statusAt)) + } + scope.launch { + sessionStore?.updatePeerStatus( + update.userId.value, + update.status, + update.statusAt, + ) + } } } @@ -314,7 +343,8 @@ internal class DialogExecutor( dispatch(Msg.UnreadCount(chat.unreadCount)) } applyUnreadCountersFromChat( - chat.unreadMentionsCount, chat.unreadReactionsCount + chat.unreadMentionsCount, + chat.unreadReactionsCount, ) if (chat.readInboxMaxId > current.readInboxMaxId) { dispatch(Msg.ReadInbox(chat.readInboxMaxId)) @@ -360,25 +390,30 @@ internal class DialogExecutor( } is MtprotoUpdate.SavedGifsChanged -> { - SavedGifMemory.clear() + memory.gifs.clear() dispatch(Msg.SavedGifsLoaded(false)) loadSavedGifs() } - is MtprotoUpdate.MessageReactions -> if (update.chatId == chatId) { - val current = state().messages.firstOrNull { it.id.id == update.messageId } - if (current?.reactionsJson != update.reactionsJson) { - dispatch(Msg.Reactions(update.messageId, update.reactionsJson)) + is MtprotoUpdate.MessageReactions -> { + if (update.chatId == chatId) { + val current = + state().messages.firstOrNull { it.id.id == update.messageId } + if (current?.reactionsJson != update.reactionsJson) { + dispatch(Msg.Reactions(update.messageId, update.reactionsJson)) + } } } - is MtprotoUpdate.DiscussionInbox -> if (update.channelId == chatId && update.topMessageId == ForumIo.historyThreadId( - threadTopMsgId - ) - ) { - val confirmed = maxOf(state().readInboxMaxId, update.readMaxId) - dispatch(Msg.ReadInbox(confirmed)) - dispatch(Msg.UnreadCount(state().messages.count { !it.outgoing && it.id.id > confirmed })) + is MtprotoUpdate.DiscussionInbox -> { + if (update.channelId == chatId && update.topMessageId == ForumIo.historyThreadId( + threadTopMsgId, + ) + ) { + val confirmed = maxOf(state().readInboxMaxId, update.readMaxId) + dispatch(Msg.ReadInbox(confirmed)) + dispatch(Msg.UnreadCount(state().messages.count { !it.outgoing && it.id.id > confirmed })) + } } is MtprotoUpdate.Ignored -> { @@ -386,15 +421,14 @@ internal class DialogExecutor( bindPendingId(randomId, messageId) return@onEach } - if (update.kind == "difference_too_long" && state().searchQuery.isBlank()) { - // updates.differenceTooLong advances pts but - // cannot carry the missing messages. Refill - // the visible dialog range from history. + if (state().searchQuery.isBlank() && (update.kind == "difference_too_long" || update.kind == "channel_difference_too_long:${chatId.value}")) { refresh() } } - else -> Unit + else -> { + Unit + } } }.launchIn(scope) refresh() @@ -404,9 +438,13 @@ internal class DialogExecutor( } internal var contactImportRevision = 0L + override fun executeIntent(intent: DialogStore.Intent) { when (intent) { - DialogStore.Intent.Refresh -> refresh() + DialogStore.Intent.Refresh -> { + refresh() + } + DialogStore.Intent.RefreshPresence -> { loadChatProfile() if (state().inForumTopic) { @@ -433,13 +471,22 @@ internal class DialogExecutor( } DialogStore.Intent.Resume -> { - if (resumeHistory && state().searchQuery.isBlank()) refresh() - else if (state().searchQuery.isNotBlank()) search(state().searchQuery) + if (resumeHistory && state().searchQuery.isBlank()) { + refresh() + } else if (state().searchQuery.isNotBlank()) { + search(state().searchQuery) + } resumeHistory = false } - DialogStore.Intent.LoadOlder -> loadOlder() - DialogStore.Intent.LoadNewer -> loadNewer() + DialogStore.Intent.LoadOlder -> { + loadOlder() + } + + DialogStore.Intent.LoadNewer -> { + loadNewer() + } + is DialogStore.Intent.SetDraftFormatting -> { dispatch(Msg.DraftFormatting(intent.enabled && isPremium())) scheduleDraftPreview() @@ -462,48 +509,81 @@ internal class DialogExecutor( scheduleLinkPreview(intent.value) } - DialogStore.Intent.FixLinkPreview -> fixLinkPreview() - DialogStore.Intent.DismissLinkPreview -> dismissLinkPreview() - DialogStore.Intent.RestoreLinkPreview -> restoreLinkPreview() - is DialogStore.Intent.SelectLinkPreview -> selectLinkPreview(intent.url) - is DialogStore.Intent.AttachPhoto -> dispatch( - Msg.PendingAttach( - listOf( - UploadItem( - path = intent.path, - kind = "photo", - fileName = java.io.File(intent.path).name, + DialogStore.Intent.FixLinkPreview -> { + fixLinkPreview() + } + + DialogStore.Intent.DismissLinkPreview -> { + dismissLinkPreview() + } + + DialogStore.Intent.RestoreLinkPreview -> { + restoreLinkPreview() + } + + is DialogStore.Intent.SelectLinkPreview -> { + selectLinkPreview(intent.url) + } + + is DialogStore.Intent.AttachPhoto -> { + dispatch( + Msg.PendingAttach( + listOf( + UploadItem( + path = intent.path, + kind = "photo", + fileName = java.io.File(intent.path).name, + ), ), ), - ), - ) + ) + } - is DialogStore.Intent.AttachMedia -> dispatch( - Msg.PendingAttach(intent.items.take(10)), - ) + is DialogStore.Intent.AttachMedia -> { + dispatch( + Msg.PendingAttach(intent.items.take(10)), + ) + } - is DialogStore.Intent.AppendMedia -> dispatch( - Msg.PendingAttach( - (state().pendingAttach + intent.items).distinctBy(UploadItem::path).take(10), - ), - ) + is DialogStore.Intent.AppendMedia -> { + dispatch( + Msg.PendingAttach( + (state().pendingAttach + intent.items).distinctBy(UploadItem::path) + .take(10), + ), + ) + } + + DialogStore.Intent.ClearAttach -> { + dispatch(Msg.PendingAttach(emptyList())) + } - DialogStore.Intent.ClearAttach -> dispatch(Msg.PendingAttach(emptyList())) is DialogStore.Intent.ReplyTo -> { dispatch(Msg.Editing(null)) dispatch(Msg.ReplyTo(intent.message, focusComposer = intent.focusComposer)) persistComposerDraft() } - DialogStore.Intent.ClearReply -> setComposerReply(null) - DialogStore.Intent.DiscardDraft -> discardComposerDraft() - DialogStore.Intent.RetryDraftSync -> warmup?.draftSync?.requestRetry() + DialogStore.Intent.ClearReply -> { + setComposerReply(null) + } + + DialogStore.Intent.DiscardDraft -> { + discardComposerDraft() + } + + DialogStore.Intent.RetryDraftSync -> { + warmup?.draftSync?.requestRetry() + } + is DialogStore.Intent.Edit -> { val checklist = intent.message.checklist val item = intent.itemId?.let { id -> checklist?.items?.singleOrNull { it.id == id } } if (intent.itemId != null && item == null) return - if (checklist != null && (!state().canCreateChecklist || !intent.message.outgoing || intent.message.pending || intent.message.id.id <= 0 || intent.message.id.chatId != chatId || intent.message.id.id in state().checklistMutations)) return + if (checklist != null && (!state().canCreateChecklist || !intent.message.outgoing || intent.message.pending || intent.message.id.id <= 0 || intent.message.id.chatId != chatId || intent.message.id.id in state().checklistMutations)) { + return + } dispatch(Msg.ReplyTo(null)) dispatch(Msg.PendingAttach(emptyList())) dispatch(Msg.Editing(intent.message, intent.itemId)) @@ -511,50 +591,108 @@ internal class DialogExecutor( dispatch(Msg.DraftFormatting(false)) dispatch( Msg.DraftEntities( - item?.entities ?: checklist?.entities ?: intent.message.entities - ) + item?.entities ?: checklist?.entities ?: intent.message.entities, + ), ) scheduleDraftPreview() } - is DialogStore.Intent.ReplaceAttachment -> replaceAttachment(intent) - DialogStore.Intent.CancelEdit -> finishComposerEdit() - is DialogStore.Intent.Delete -> delete(intent.messageId, intent.revoke) - is DialogStore.Intent.ForwardPick -> openForward(intent.message) + is DialogStore.Intent.ReplaceAttachment -> { + replaceAttachment(intent) + } + + DialogStore.Intent.CancelEdit -> { + finishComposerEdit() + } + + is DialogStore.Intent.Delete -> { + delete(intent.messageId, intent.revoke) + } + + is DialogStore.Intent.ForwardPick -> { + openForward(intent.message) + } + DialogStore.Intent.ClearForward -> { dispatch(Msg.ForwardMessage(null)) dispatch(Msg.ForwardQuery("")) } - is DialogStore.Intent.ForwardQuery -> dispatch(Msg.ForwardQuery(intent.value)) - is DialogStore.Intent.ForwardTo -> forwardTo(intent.chatId) - DialogStore.Intent.ClearForwardHint -> dispatch(Msg.ForwardHint(null)) - is DialogStore.Intent.LoadContactImport -> loadContactImport(intent.spec) - is DialogStore.Intent.ImportContact -> importContact(intent.spec, intent.retry) - is DialogStore.Intent.SendContact -> queueContactSend(intent.spec, intent.options) - is DialogStore.Intent.SendChecklist -> queueChecklistSend(intent.spec, intent.options) - is DialogStore.Intent.SendPoll -> queuePollSend(intent.spec, intent.options) - is DialogStore.Intent.SendWithOptions -> if (snapshot().pendingAttach.isEmpty()) queueTextSend( - intent.text, intent.options - ) - else queueMediaSend(intent.text, intent.options) + is DialogStore.Intent.ForwardQuery -> { + dispatch(Msg.ForwardQuery(intent.value)) + } - is DialogStore.Intent.RetryMediaSend -> work.launch { - warmup?.retryOutgoingMediaSend( - intent.batchId - ) - }.let { Unit } + is DialogStore.Intent.ForwardTo -> { + forwardTo(intent.chatId) + } - is DialogStore.Intent.RetryTextSend -> work.launch { - warmup?.retryOutgoingTextSend( - intent.randomId - ) - }.let { Unit } + DialogStore.Intent.ClearForwardHint -> { + dispatch(Msg.ForwardHint(null)) + } + + is DialogStore.Intent.LoadContactImport -> { + loadContactImport(intent.spec) + } + + is DialogStore.Intent.ImportContact -> { + importContact(intent.spec, intent.retry) + } + + is DialogStore.Intent.SendContact -> { + queueContactSend(intent.spec, intent.options) + } + + is DialogStore.Intent.SendChecklist -> { + queueChecklistSend(intent.spec, intent.options) + } + + is DialogStore.Intent.SendPoll -> { + queuePollSend(intent.spec, intent.options) + } + + is DialogStore.Intent.SendWithOptions -> { + if (snapshot().pendingAttach.isEmpty()) { + queueTextSend( + intent.text, + intent.options, + ) + } else { + queueMediaSend(intent.text, intent.options) + } + } + + is DialogStore.Intent.RetryMediaSend -> { + work.launch { + warmup?.retryOutgoingMediaSend( + intent.batchId, + ) + }.let { Unit } + } + + is DialogStore.Intent.RetryTextSend -> { + work.launch { + warmup?.retryOutgoingTextSend( + intent.randomId, + ) + }.let { Unit } + } + + is DialogStore.Intent.Send -> { + send(intent.text) + } + + DialogStore.Intent.RetryFailed -> { + retryFailed() + } + + is DialogStore.Intent.Search -> { + search(intent.query) + } + + is DialogStore.Intent.JumpToDate -> { + jump(intent.epochSeconds) + } - is DialogStore.Intent.Send -> send(intent.text) - DialogStore.Intent.RetryFailed -> retryFailed() - is DialogStore.Intent.Search -> search(intent.query) - is DialogStore.Intent.JumpToDate -> jump(intent.epochSeconds) DialogStore.Intent.ClearSearch -> { dispatch(Msg.SearchQuery("")) refresh() @@ -566,13 +704,34 @@ internal class DialogExecutor( } } - DialogStore.Intent.NextPinned -> nextPinned() - DialogStore.Intent.OpenPinnedList -> dispatch(Msg.PinnedListOpen(true)) - DialogStore.Intent.ClosePinnedList -> dispatch(Msg.PinnedListOpen(false)) - is DialogStore.Intent.JumpToPinned -> jumpToPinned(intent.messageId) - is DialogStore.Intent.JumpToMessage -> jumpToMessage(intent.messageId) - DialogStore.Intent.JumpMention -> jumpMention() - DialogStore.Intent.JumpUnreadReaction -> jumpUnreadReaction() + DialogStore.Intent.NextPinned -> { + nextPinned() + } + + DialogStore.Intent.OpenPinnedList -> { + dispatch(Msg.PinnedListOpen(true)) + } + + DialogStore.Intent.ClosePinnedList -> { + dispatch(Msg.PinnedListOpen(false)) + } + + is DialogStore.Intent.JumpToPinned -> { + jumpToPinned(intent.messageId) + } + + is DialogStore.Intent.JumpToMessage -> { + jumpToMessage(intent.messageId) + } + + DialogStore.Intent.JumpMention -> { + jumpMention() + } + + DialogStore.Intent.JumpUnreadReaction -> { + jumpUnreadReaction() + } + DialogStore.Intent.JumpUnread -> { val current = state() val index = unreadDividerIndex( @@ -581,7 +740,9 @@ internal class DialogExecutor( current.readInboxMaxId, current.hasNewer, ) - val id = index?.let { current.messages.getOrNull(it)?.id?.id } + val id = index?.let { + current.messages.getOrNull(it)?.id?.id + } if (id != null) dispatch(Msg.Anchor(id, atTop = true)) } @@ -601,11 +762,26 @@ internal class DialogExecutor( markRead() } - DialogStore.Intent.MarkRead -> markRead() - is DialogStore.Intent.ReadReceipts -> readReceiptsEnabled = intent.enabled - is DialogStore.Intent.VisibleRead -> visibleRead(intent.messageId, intent.atLiveEdge) - is DialogStore.Intent.VisibleWindow -> consumeVisibleUnread(intent.messageIds) - DialogStore.Intent.ToggleGifPicker -> openEmojiTab(ComposerPanels.TAB_GIFS) + DialogStore.Intent.MarkRead -> { + markRead() + } + + is DialogStore.Intent.ReadReceipts -> { + readReceiptsEnabled = intent.enabled + } + + is DialogStore.Intent.VisibleRead -> { + visibleRead(intent.messageId, intent.atLiveEdge) + } + + is DialogStore.Intent.VisibleWindow -> { + consumeVisibleUnread(intent.messageIds) + } + + DialogStore.Intent.ToggleGifPicker -> { + openEmojiTab(ComposerPanels.TAB_GIFS) + } + DialogStore.Intent.ToggleAttachSheet -> { val open = state().composerPanel != ComposerPanels.ATTACH dispatch(Msg.ComposerPanel(if (open) ComposerPanels.ATTACH else null)) @@ -627,7 +803,10 @@ internal class DialogExecutor( } } - is DialogStore.Intent.SetEmojiTab -> openEmojiTab(intent.tab) + is DialogStore.Intent.SetEmojiTab -> { + openEmojiTab(intent.tab) + } + DialogStore.Intent.ReloadPickerContent -> { loadSavedGifs() loadStickerCatalog(emoji = false) @@ -639,106 +818,196 @@ internal class DialogExecutor( } } - is DialogStore.Intent.SendUpload -> sendUpload( - path = intent.path, - kind = intent.kind, - fileName = intent.fileName, - mimeType = intent.mimeType, - duration = intent.duration, - width = intent.width, - height = intent.height, - ) + is DialogStore.Intent.SendUpload -> { + sendUpload( + path = intent.path, + kind = intent.kind, + fileName = intent.fileName, + mimeType = intent.mimeType, + duration = intent.duration, + width = intent.width, + height = intent.height, + ) + } - is DialogStore.Intent.SendAlbum -> sendAlbum(intent.items) - is DialogStore.Intent.OpenStickerPack -> loadStickerPack( - intent.setId, intent.accessHash - ) + is DialogStore.Intent.SendAlbum -> { + sendAlbum(intent.items) + } - DialogStore.Intent.LoadSavedGifs -> loadSavedGifs() - is DialogStore.Intent.LoadReadReceipts -> loadReadReceipts(intent.messageId) - is DialogStore.Intent.LoadReactionUsers -> loadReactionUsers(intent.messageId) - is DialogStore.Intent.LoadPollVoters -> loadPollVoters(intent.messageId) - is DialogStore.Intent.SendSavedGif -> sendSavedGif(intent.documentId) - is DialogStore.Intent.React -> scope.launch { - val current = state().messages.firstOrNull { it.id.id == intent.messageId } - val json = org.monogram.core.models.toggleChosenReaction( - current?.reactionsJson, - intent.emoticon, - intent.documentId, + is DialogStore.Intent.OpenStickerPack -> { + loadStickerPack( + intent.setId, + intent.accessHash, ) - if (current?.reactionsJson != json) { - dispatch(Msg.Reactions(intent.messageId, json)) - } - when (val result = client.sendReaction( - chatId, - intent.messageId, - intent.emoticon, - intent.documentId, - )) { - is Outcome.Ok -> Unit - is Outcome.Err -> { - current?.reactionsJson?.let { - dispatch(Msg.Reactions(intent.messageId, it)) + } + + DialogStore.Intent.LoadSavedGifs -> { + loadSavedGifs() + } + + is DialogStore.Intent.LoadReadReceipts -> { + loadReadReceipts(intent.messageId) + } + + is DialogStore.Intent.LoadReactionUsers -> { + loadReactionUsers(intent.messageId) + } + + is DialogStore.Intent.LoadPollVoters -> { + loadPollVoters(intent.messageId) + } + + is DialogStore.Intent.SendSavedGif -> { + sendSavedGif(intent.documentId) + } + + is DialogStore.Intent.React -> { + scope.launch { + val current = state().messages.firstOrNull { it.id.id == intent.messageId } + val json = org.monogram.core.models.toggleChosenReaction( + current?.reactionsJson, + intent.emoticon, + intent.documentId, + ) + if (current?.reactionsJson != json) { + dispatch(Msg.Reactions(intent.messageId, json)) + } + when (val result = client.sendReaction( + chatId, + intent.messageId, + intent.emoticon, + intent.documentId, + )) { + is Outcome.Ok -> { + Unit + } + + is Outcome.Err -> { + current?.reactionsJson?.let { + dispatch(Msg.Reactions(intent.messageId, it)) + } + handleError(result.telegramError, false) } - handleError(result.telegramError, false) } } } - is DialogStore.Intent.OpenComments -> scope.launch { - when (val result = client.getDiscussionMessage(chatId, intent.message.id.id)) { - is Outcome.Ok -> dispatch( - Msg.PendingChat(result.value.chatId, result.value.messageId), - ) + is DialogStore.Intent.OpenComments -> { + scope.launch { + when (val result = client.getDiscussionMessage(chatId, intent.message.id.id)) { + is Outcome.Ok -> { + dispatch( + Msg.PendingChat(result.value.chatId, result.value.messageId), + ) + } - is Outcome.Err -> handleError(result.telegramError, false) + is Outcome.Err -> { + handleError(result.telegramError, false) + } + } } } - DialogStore.Intent.ClearPendingChat -> dispatch(Msg.PendingChat(null, null)) - DialogStore.Intent.LoadMoreTopics -> loadMoreTopics() - is DialogStore.Intent.ToggleTopicHidden -> toggleTopicHidden( - intent.topicId, intent.hidden - ) + DialogStore.Intent.ClearPendingChat -> { + dispatch(Msg.PendingChat(null, null)) + } - is DialogStore.Intent.SendInlineResult -> sendInlineResult(intent.resultId) - DialogStore.Intent.LoadMoreInlineResults -> loadMoreInlineResults() - DialogStore.Intent.RetryInlineResults -> retryInlineResults() - is DialogStore.Intent.SelectMention -> selectMention(intent.candidate) - DialogStore.Intent.LoadMoreMentions -> loadMoreMentions() - is DialogStore.Intent.RemoveChecklistItem -> removeChecklistItem( - intent.message, intent.itemId - ) + DialogStore.Intent.LoadMoreTopics -> { + loadMoreTopics() + } - is DialogStore.Intent.ToggleChecklist -> toggleChecklist( - intent.messageId, intent.itemId - ) + is DialogStore.Intent.ToggleTopicHidden -> { + toggleTopicHidden( + intent.topicId, + intent.hidden, + ) + } - is DialogStore.Intent.ClosePoll -> closePoll(intent.messageId) - is DialogStore.Intent.VotePoll -> votePoll(intent.messageId, intent.options) - is DialogStore.Intent.AppendChecklistItems -> appendChecklistItems( - intent.messageId, - intent.firstId, - intent.titles - ) + is DialogStore.Intent.SendInlineResult -> { + sendInlineResult(intent.resultId) + } - is DialogStore.Intent.SendLocation -> sendLocation( - intent.latitude, - intent.longitude, - intent.livePeriodSeconds - ) + DialogStore.Intent.LoadMoreInlineResults -> { + loadMoreInlineResults() + } - is DialogStore.Intent.BotButton -> pressBotButton( - intent.messageId, - intent.button, - intent.fromKeyboard, - ) + DialogStore.Intent.RetryInlineResults -> { + retryInlineResults() + } + + is DialogStore.Intent.SelectMention -> { + selectMention(intent.candidate) + } + + DialogStore.Intent.LoadMoreMentions -> { + loadMoreMentions() + } + + is DialogStore.Intent.RemoveChecklistItem -> { + removeChecklistItem( + intent.message, + intent.itemId, + ) + } + + is DialogStore.Intent.ToggleChecklist -> { + toggleChecklist( + intent.messageId, + intent.itemId, + ) + } + + is DialogStore.Intent.ClosePoll -> { + closePoll(intent.messageId) + } - DialogStore.Intent.ClearBotNotice -> dispatch(Msg.BotNotice(null)) - DialogStore.Intent.DismissBotAlert -> dispatch(Msg.BotNotice(null)) - DialogStore.Intent.ClearBotUrl -> dispatch(Msg.BotUrl(null)) - DialogStore.Intent.ClearCopyText -> dispatch(Msg.CopyText(null)) - DialogStore.Intent.ReadOnlyBar -> readOnlyBar() + is DialogStore.Intent.VotePoll -> { + votePoll(intent.messageId, intent.options) + } + + is DialogStore.Intent.AppendChecklistItems -> { + appendChecklistItems( + intent.messageId, + intent.firstId, + intent.titles, + ) + } + + is DialogStore.Intent.SendLocation -> { + sendLocation( + intent.latitude, + intent.longitude, + intent.livePeriodSeconds, + ) + } + + is DialogStore.Intent.BotButton -> { + pressBotButton( + intent.messageId, + intent.button, + intent.fromKeyboard, + ) + } + + DialogStore.Intent.ClearBotNotice -> { + dispatch(Msg.BotNotice(null)) + } + + DialogStore.Intent.DismissBotAlert -> { + dispatch(Msg.BotNotice(null)) + } + + DialogStore.Intent.ClearBotUrl -> { + dispatch(Msg.BotUrl(null)) + } + + DialogStore.Intent.ClearCopyText -> { + dispatch(Msg.CopyText(null)) + } + + DialogStore.Intent.ReadOnlyBar -> { + readOnlyBar() + } } } @@ -794,7 +1063,8 @@ internal class DialogExecutor( cachedChat?.unreadCount?.let { dispatch(Msg.UnreadCount(it)) } cachedChat?.let { applyUnreadCountersFromChat( - it.unreadMentionsCount, it.unreadReactionsCount + it.unreadMentionsCount, + it.unreadReactionsCount, ) } cachedChat?.readInboxMaxId?.let { dispatch(Msg.ReadInbox(it)) } @@ -834,7 +1104,11 @@ internal class DialogExecutor( if (historyTop > 0) { val savedRead = warmup?.let { cache -> if (cache.usesIoDispatcher) { - withContext(Dispatchers.IO) { cache.discussionReadMax(chatId, historyTop) } + withContext(Dispatchers.IO) { + cache.discussionReadMax( + chatId, historyTop + ) + } } else { cache.discussionReadMax(chatId, historyTop) } @@ -851,20 +1125,26 @@ internal class DialogExecutor( if (gen != historyGen) return@launch serverHistoryBoundaryId = result.value.minOfOrNull { it.id.id } publishPaintedHistory( - result.value, fromCache = false, liveEdge = true, generation = gen + result.value, + fromCache = false, + liveEdge = true, + generation = gen, ) if (gen != historyGen) return@launch resolveSendersLater(state().messages) dispatch( Msg.HasOlder( historyHasMore( - result.value.size, HISTORY_FIRST_LIMIT - ) || cachedOlderTail.isNotEmpty() - ) + result.value.size, + HISTORY_FIRST_LIMIT, + ) || cachedOlderTail.isNotEmpty(), + ), ) } - is Outcome.Err -> handleError(result.telegramError, true) + is Outcome.Err -> { + handleError(result.telegramError, true) + } } if (gen != historyGen) return@launch dispatch(Msg.Loading(false)) @@ -874,14 +1154,21 @@ internal class DialogExecutor( val remembered = warmup?.recentHistory?.peek(chatId.value).orEmpty() if (remembered.isNotEmpty()) { publishPaintedHistory( - remembered, fromCache = true, liveEdge = true, generation = gen + remembered, + fromCache = true, + liveEdge = true, + generation = gen, ) if (gen != historyGen) return@launch dispatch(Msg.Loading(false)) } val persisted = warmup?.let { cache -> if (cache.usesIoDispatcher) { - withContext(Dispatchers.IO) { cache.messages(chatId, HISTORY_CACHE_LIMIT) } + withContext(Dispatchers.IO) { + cache.messages( + chatId, HISTORY_CACHE_LIMIT + ) + } } else { cache.messages(chatId, HISTORY_CACHE_LIMIT) } @@ -896,7 +1183,9 @@ internal class DialogExecutor( "dialog", "cache hit chat=${chatId.value} count=${cached.size} lastId=$lastId", ) - publishPaintedHistory(cached, fromCache = true, liveEdge = true, generation = gen) + publishPaintedHistory( + cached, fromCache = true, liveEdge = true, generation = gen + ) if (gen != historyGen) return@launch dispatch(Msg.Loading(false)) resolveSendersLater(state().messages) @@ -914,9 +1203,13 @@ internal class DialogExecutor( } } val slice = warmup?.historySlice(chatId, limit = HISTORY_FIRST_LIMIT) + if (gen != historyGen) return@launch if (slice != null && slice.rows.isNotEmpty() && cached.isEmpty()) { publishPaintedHistory( - slice.rows, fromCache = true, liveEdge = true, generation = gen + slice.rows, + fromCache = true, + liveEdge = true, + generation = gen, ) } val sliceNewestId = slice?.rows?.maxOfOrNull { it.id.id } ?: newestCachedId @@ -928,6 +1221,7 @@ internal class DialogExecutor( dispatch(Msg.Loading(false)) loadPinned() loadChatProfile() + completeInitialHistoryNavigation() return@launch } AppLog.api( @@ -955,14 +1249,17 @@ internal class DialogExecutor( ) serverHistoryBoundaryId = result.value.minOfOrNull { it.id.id } publishPaintedHistory( - result.value, fromCache = false, liveEdge = true, generation = gen + result.value, + fromCache = false, + liveEdge = true, + generation = gen, ) if (gen != historyGen) return@launch dispatch( Msg.HasOlder( historyHasMore( result.value.size, - HISTORY_FIRST_LIMIT + HISTORY_FIRST_LIMIT, ) || cached.size > result.value.size || cachedOlderTail.isNotEmpty(), ), ) @@ -994,19 +1291,23 @@ internal class DialogExecutor( dispatch(Msg.Loading(false)) loadPinned() loadChatProfile() - val jump = pendingJump - if (jump > 0) { - pendingJump = 0 - jumpToMessageId(jump) - } else { - continueUnreadPaging() - } + completeInitialHistoryNavigation() + } + } + + private fun completeInitialHistoryNavigation() { + val jump = pendingJump + if (jump > 0) { + pendingJump = 0 + jumpToMessageId(jump) + } else { + continueUnreadPaging() } } internal fun rememberPinned() { val current = state() - val evicted = PinnedBarMemory.put(chatId.value, current.pinnedMessages, current.pinnedIndex) + val evicted = memory.pins.put(chatId.value, current.pinnedMessages, current.pinnedIndex) val ids = current.pinnedMessages.map { it.id.id } scope.launch { forgetEvictedDialogMeta(sessionStore, evicted, ::pinnedMetaKey) @@ -1027,7 +1328,8 @@ internal class DialogExecutor( if (!keepTopicTitle) { val title = preferredPeerTitle(profile.title, state().title, chatId.value) if (!isPlaceholderPeerTitle( - title, chatId.value + title, + chatId.value, ) && state().title != title ) { dispatch(Msg.Title(title)) @@ -1048,7 +1350,9 @@ internal class DialogExecutor( dispatch(Msg.IsChannel(false)) } - else -> Unit + else -> { + Unit + } } val shown = shownMuted() if (state().muted != shown) dispatch(Msg.Membership(muted = shown)) @@ -1072,7 +1376,9 @@ internal class DialogExecutor( if (state().isGroup) loadAdminTags() } - is Outcome.Err -> Unit + is Outcome.Err -> { + Unit + } } } } @@ -1084,12 +1390,14 @@ internal class DialogExecutor( is Outcome.Ok -> { val tags = mergeSenderTags(result.value, state().senders) dispatch(Msg.SenderTags(tags)) - val evicted = SenderTagMemory.put(chatId.value, tags) + val evicted = memory.tags.put(chatId.value, tags) forgetEvictedDialogMeta(sessionStore, evicted, ::tagsMetaKey) sessionStore?.writeMeta(tagsMetaKey(chatId.value), encodeSenderTags(tags)) } - is Outcome.Err -> Unit + is Outcome.Err -> { + Unit + } } } } @@ -1133,7 +1441,10 @@ internal class DialogExecutor( } } - private fun applyPeerNotify(muteUntil: Int, specified: Boolean) { + private fun applyPeerNotify( + muteUntil: Int, + specified: Boolean, + ) { if (!specified) { if (muteOverride) return val shown = shownMuted() @@ -1184,7 +1495,9 @@ internal class DialogExecutor( rememberChat { it.copy(muted = muted, muteOverride = true) } } - is Outcome.Err -> dispatch(Msg.Error(result.telegramError)) + is Outcome.Err -> { + dispatch(Msg.Error(result.telegramError)) + } } } } @@ -1212,7 +1525,9 @@ internal class DialogExecutor( } } - is Outcome.Err -> dispatch(Msg.Error(result.telegramError)) + is Outcome.Err -> { + dispatch(Msg.Error(result.telegramError)) + } } } } @@ -1227,7 +1542,9 @@ internal class DialogExecutor( } } - is Outcome.Err -> dispatch(Msg.Error(result.telegramError)) + is Outcome.Err -> { + dispatch(Msg.Error(result.telegramError)) + } } } } @@ -1301,7 +1618,7 @@ internal class DialogExecutor( dispatch(Msg.Senders(resolved)) val tags = mergeSenderTags(state().senderTags, state().senders) dispatch(Msg.SenderTags(tags)) - SenderTagMemory.put(chatId.value, tags) + memory.tags.put(chatId.value, tags) } resolved.entries.filter { it.value.avatarCacheKey.isNullOrBlank() }.take(8) .forEach { (id, _) -> @@ -1311,7 +1628,9 @@ internal class DialogExecutor( sessionStore?.upsertProfile(result.value) } - is Outcome.Err -> Unit + is Outcome.Err -> { + Unit + } } } } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogForum.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogForum.kt index 11b8a595d..1a6770f17 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogForum.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogForum.kt @@ -8,20 +8,15 @@ import org.monogram.core.common.telegram.TelegramError import org.monogram.core.models.Chat import org.monogram.core.models.ForumIo import org.monogram.core.models.ForumTopic -import org.monogram.feature.dialog.TopicListMemory import org.monogram.feature.dialog.encodeForumTopics import org.monogram.feature.dialog.forgetEvictedDialogMeta +import org.monogram.feature.dialog.parseForumTopics import org.monogram.feature.dialog.topicsMetaKey internal fun DialogExecutor.probesTopics(cachedChat: Chat?): Boolean = - cachedChat?.isForum == true || - snapshot().isForum || - ( - forumUnknown && - cachedChat == null && - threadTopMsgId <= 0 && - ForumIo.isChannelPeer(chatId) - ) + cachedChat?.isForum == true || snapshot().isForum || (forumUnknown && cachedChat == null && threadTopMsgId <= 0 && ForumIo.isChannelPeer( + chatId + )) internal fun DialogExecutor.loadTopicHeader() { val topicId = threadTopMsgId @@ -56,8 +51,7 @@ internal fun DialogExecutor.loadTopicHeader() { internal fun DialogExecutor.isNotForumError(error: TelegramError): Boolean { val type = error.type.uppercase() - return type.contains("CHANNEL_FORUM_MISSING") || - type.contains("CHANNEL_MONOFORUM") + return type.contains("CHANNEL_FORUM_MISSING") || type.contains("CHANNEL_MONOFORUM") } internal suspend fun DialogExecutor.persistForumFlag(isForum: Boolean) { @@ -68,16 +62,16 @@ internal suspend fun DialogExecutor.persistForumFlag(isForum: Boolean) { internal suspend fun DialogExecutor.hydrateTopics() { if (snapshot().topics.isNotEmpty()) return - val ram = TopicListMemory.get(chatId.value) + val ram = memory.topics.get(chatId.value) if (ram.isNotEmpty()) { emit(Msg.Topics(ram, ram.size, append = false)) return } - val parsed = org.monogram.feature.dialog.parseForumTopics( + val parsed = parseForumTopics( sessionStore?.readMeta(topicsMetaKey(chatId.value)), ) if (parsed.isEmpty()) return - TopicListMemory.put(chatId.value, parsed) + memory.topics.put(chatId.value, parsed) AppLog.api("dialog", "topics cache chat=${chatId.value} count=${parsed.size}") emit(Msg.Topics(parsed, parsed.size, append = false)) } @@ -101,14 +95,12 @@ internal fun DialogExecutor.loadTopics(reset: Boolean, gen: Int = historyGen) { offsetTopic = last?.id ?: 0 } AppLog.api("dialog", "getForumTopics start chat=${chatId.value}") - when ( - val result = client.getForumTopics( - chatId, - offsetDate = offsetDate, - offsetId = offsetId, - offsetTopic = offsetTopic, - ) - ) { + when (val result = client.getForumTopics( + chatId, + offsetDate = offsetDate, + offsetId = offsetId, + offsetTopic = offsetTopic, + )) { is Outcome.Ok -> { if (gen != historyGen) return@launch val page = result.value @@ -117,7 +109,7 @@ internal fun DialogExecutor.loadTopics(reset: Boolean, gen: Int = historyGen) { persistForumFlag(true) emit(Msg.Topics(page.topics, page.count, append = !reset)) if (reset) { - TopicListMemory.put(chatId.value, page.topics) + memory.topics.put(chatId.value, page.topics) sessionStore?.writeMeta( topicsMetaKey(chatId.value), encodeForumTopics(page.topics), @@ -178,7 +170,7 @@ internal fun DialogExecutor.toggleTopicHidden(topicId: Int, hidden: Boolean) { } internal suspend fun DialogExecutor.persistForumTopics(topics: List) { - val evicted = TopicListMemory.put(chatId.value, topics) + val evicted = memory.topics.put(chatId.value, topics) forgetEvictedDialogMeta(sessionStore, evicted, ::topicsMetaKey) sessionStore?.writeMeta( topicsMetaKey(chatId.value), diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogHistory.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogHistory.kt index 8974413c4..76368ce07 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogHistory.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogHistory.kt @@ -28,7 +28,7 @@ import kotlin.time.Duration.Companion.milliseconds internal suspend fun DialogExecutor.hydrateSenderTags(generation: Int = historyGen) { if (generation != historyGen || snapshot().senderTags.isNotEmpty()) return - val ram = org.monogram.feature.dialog.SenderTagMemory.get(chatId.value) + val ram = memory.tags.get(chatId.value) if (ram.isNotEmpty()) { emit(Msg.SenderTags(ram)) return @@ -37,7 +37,7 @@ internal suspend fun DialogExecutor.hydrateSenderTags(generation: Int = historyG sessionStore?.readMeta(org.monogram.feature.dialog.tagsMetaKey(chatId.value)), ) if (generation != historyGen || snapshot().senderTags.isNotEmpty() || parsed.isEmpty()) return - org.monogram.feature.dialog.SenderTagMemory.put(chatId.value, parsed) + memory.tags.put(chatId.value, parsed) emit(Msg.SenderTags(parsed)) } @@ -69,10 +69,13 @@ internal fun DialogExecutor.loadPinned() { work.launch { when (val result = client.getPinnedMessages(chatId)) { is Outcome.Ok -> { - AppLog.api("dialog", "pinned network chat=${chatId.value} count=${result.value.size}") + AppLog.api( + "dialog", "pinned network chat=${chatId.value} count=${result.value.size}" + ) emit(Msg.Pinned(result.value)) rememberPinned() } + is Outcome.Err -> Unit } } @@ -85,9 +88,7 @@ internal fun DialogExecutor.nextPinned() { return } var index = current.pinnedIndex.coerceIn(0, current.pinnedMessages.lastIndex) - if (current.anchorMessageId == current.pinnedMessages[index].id.id && - current.pinnedMessages.size > 1 - ) { + if (current.anchorMessageId == current.pinnedMessages[index].id.id && current.pinnedMessages.size > 1) { index = nextPinnedIndex(index, current.pinnedMessages.size) emit(Msg.PinnedIndex(index)) rememberPinned() @@ -154,12 +155,10 @@ internal fun DialogExecutor.jumpToMessageId(messageId: Int, atTop: Boolean = fal } } } - when ( - val result = topicOrHistoryPage( - offsetId = messageId, - addOffset = -20, - ) - ) { + when (val result = topicOrHistoryPage( + offsetId = messageId, + addOffset = -20, + )) { is Outcome.Ok -> { if (gen != historyGen) return@launch // A jump replaces the visible window with a server page, so its @@ -179,6 +178,7 @@ internal fun DialogExecutor.jumpToMessageId(messageId: Int, atTop: Boolean = fal warmup?.upsertMessages(result.value) sessionStore?.upsertPeerMins(result.value) } + is Outcome.Err -> if (gen == historyGen) { handleError(result.telegramError, false) } @@ -322,12 +322,10 @@ internal fun DialogExecutor.loadOlder(prefetch: Boolean = false) { if (!ownsRequest()) return@launch if (slice != null && slice.rows.isNotEmpty()) { emit(Msg.AppendOlder(slice.rows)) - serverHistoryBoundaryId = slice.rows.minOf { it.id.id } resolveSendersLater(slice.rows) } - if (slice != null && slice.covered && - (slice.rows.size == HISTORY_PAGE_LIMIT || slice.confirmedStart) - ) { + if (slice != null && slice.covered && (slice.rows.size == HISTORY_PAGE_LIMIT || slice.confirmedStart)) { + slice.rows.minOfOrNull { it.id.id }?.let { serverHistoryBoundaryId = it } emit(Msg.HasOlder(!slice.confirmedStart)) emit(Msg.LoadingOlder(false)) activeOlderRequestId = null @@ -335,23 +333,21 @@ internal fun DialogExecutor.loadOlder(prefetch: Boolean = false) { return@launch } } - when ( - val result = if (historyTop > 0) { - client.getReplies( - chatId, - historyTop, - HISTORY_PAGE_LIMIT, - offsetId = boundaryId, - ) - } else { - client.getHistoryPage( - chatId = chatId, - limit = HISTORY_PAGE_LIMIT, - offsetId = boundaryId, - addOffset = 0, - ) - } - ) { + when (val result = if (historyTop > 0) { + client.getReplies( + chatId, + historyTop, + HISTORY_PAGE_LIMIT, + offsetId = boundaryId, + ) + } else { + client.getHistoryPage( + chatId = chatId, + limit = HISTORY_PAGE_LIMIT, + offsetId = boundaryId, + addOffset = 0, + ) + }) { is Outcome.Ok -> { if (!ownsRequest()) return@launch val fresh = result.value.count { it.id.id < boundaryId } @@ -387,6 +383,7 @@ internal fun DialogExecutor.loadOlder(prefetch: Boolean = false) { continueUnreadPaging() resolveSendersLater(result.value) } + is Outcome.Err -> if (ownsRequest()) { // A failed page is not an end-of-history signal. Preserve the cursor and // keep the retry path available for transient network/offline failures. @@ -491,20 +488,16 @@ internal fun DialogExecutor.loadNewer() { if (newerSlice != null && newerSlice.rows.isNotEmpty()) { emit(Msg.Prepend(newerSlice.rows)) } - if (newerSlice != null && newerSlice.covered && - (newerSlice.rows.size == HISTORY_PAGE_LIMIT || newerSlice.confirmedEnd) - ) { + if (newerSlice != null && newerSlice.covered && (newerSlice.rows.size == HISTORY_PAGE_LIMIT || newerSlice.confirmedEnd)) { emit(Msg.HasNewer(!newerSlice.confirmedEnd)) emit(Msg.LoadingNewer(false)) activeNewerRequestId = null return@launch } - when ( - val result = topicOrHistoryPage( - offsetId = newest.id.id, - addOffset = -HISTORY_PAGE_LIMIT, - ) - ) { + when (val result = topicOrHistoryPage( + offsetId = newest.id.id, + addOffset = -HISTORY_PAGE_LIMIT, + )) { is Outcome.Ok -> { if (!ownsRequest()) return@launch emit(Msg.Prepend(result.value)) @@ -515,6 +508,7 @@ internal fun DialogExecutor.loadNewer() { sessionStore?.upsertPeerMins(result.value) resolveSendersLater(result.value) } + is Outcome.Err -> if (ownsRequest()) { // A transient newer-page failure is retryable; it is not proof that the // server has no newer messages. @@ -578,9 +572,7 @@ internal fun DialogExecutor.jump(epochSeconds: Int) { emit(Msg.HasOlder(true)) } } - when ( - val result = topicOrHistoryPage(offsetDate = epochSeconds) - ) { + when (val result = topicOrHistoryPage(offsetDate = epochSeconds)) { is Outcome.Ok -> if (gen == historyGen) { val minId = result.value.minOfOrNull { it.id.id } serverHistoryBoundaryId = minId @@ -592,6 +584,7 @@ internal fun DialogExecutor.jump(epochSeconds: Int) { sessionStore?.upsertPeerMins(result.value) resolveSendersLater(result.value) } + is Outcome.Err -> if (gen == historyGen) handleError(result.telegramError, false) } if (gen == historyGen) emit(Msg.Loading(false)) @@ -633,6 +626,7 @@ internal fun DialogExecutor.search(query: String) { it.text.orEmpty().contains(query, ignoreCase = true) }, ) + is Outcome.Err -> replies } } else { @@ -651,6 +645,7 @@ internal fun DialogExecutor.search(query: String) { ) resolveSendersLater(result.value) } + is Outcome.Err -> handleError(result.telegramError, true) } } finally { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogStickers.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogStickers.kt index 6774e0df4..0048d8c16 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogStickers.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogStickers.kt @@ -5,10 +5,7 @@ import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome import org.monogram.feature.dialog.ComposerPanels import org.monogram.feature.dialog.PickerDisk -import org.monogram.feature.dialog.SavedGifMemory -import org.monogram.feature.dialog.StickerCatalogMemory import org.monogram.feature.dialog.StickerCatalogSnapshot -import org.monogram.feature.dialog.StickerPackMemory internal fun DialogExecutor.warmPickerCatalogs() { warmStickerCatalog(emoji = false) @@ -17,50 +14,50 @@ internal fun DialogExecutor.warmPickerCatalogs() { } private fun DialogExecutor.warmStickerCatalog(emoji: Boolean) { - StickerCatalogMemory.get(emoji)?.let { cached -> + memory.catalogs.get(emoji)?.let { cached -> if (emoji) { if (snapshot().emojiSets.isEmpty()) emit(Msg.EmojiSets(cached.sets)) } else if (snapshot().stickerSets.isEmpty()) { emit(Msg.StickerSets(cached.sets)) } - if (!StickerCatalogMemory.isLive(emoji)) refreshStickerCatalog(emoji, cached.hash) + if (!memory.catalogs.isLive(emoji)) refreshStickerCatalog(emoji, cached.hash) return } val store = sessionStore ?: return work.launch { val cached = PickerDisk.readCatalog(store, emoji) ?: return@launch - if (StickerCatalogMemory.get(emoji) != null) return@launch - StickerCatalogMemory.put(emoji, cached) + if (memory.catalogs.get(emoji) != null) return@launch + memory.catalogs.put(emoji, cached) if (emoji) emit(Msg.EmojiSets(cached.sets)) else emit(Msg.StickerSets(cached.sets)) refreshStickerCatalog(emoji, cached.hash) } } private fun DialogExecutor.warmSavedGifs() { - SavedGifMemory.get()?.let { + memory.gifs.get()?.let { if (!snapshot().savedGifsLoaded) emit(Msg.SavedGifs(it)) - if (!SavedGifMemory.isLive()) refreshSavedGifs() + if (!memory.gifs.isLive()) refreshSavedGifs() return } val store = sessionStore ?: return work.launch { val cached = PickerDisk.readGifs(store) ?: return@launch - if (SavedGifMemory.get() != null) return@launch - SavedGifMemory.put(cached) + if (memory.gifs.get() != null) return@launch + memory.gifs.put(cached) emit(Msg.SavedGifs(cached)) refreshSavedGifs() } } internal fun DialogExecutor.loadSavedGifs() { - SavedGifMemory.get()?.let { + memory.gifs.get()?.let { emit(Msg.SavedGifs(it)) - if (!SavedGifMemory.isLive()) refreshSavedGifs() + if (!memory.gifs.isLive()) refreshSavedGifs() return } work.launch { sessionStore?.let { PickerDisk.readGifs(it) }?.let { cached -> - SavedGifMemory.put(cached) + memory.gifs.put(cached) emit(Msg.SavedGifs(cached)) } refreshSavedGifs() @@ -71,11 +68,12 @@ internal fun DialogExecutor.refreshSavedGifs() { work.launch { when (val result = client.getSavedGifs()) { is Outcome.Ok -> { - SavedGifMemory.put(result.value) - SavedGifMemory.markLive() + memory.gifs.put(result.value) + memory.gifs.markLive() sessionStore?.let { PickerDisk.writeGifs(it, result.value) } emit(Msg.SavedGifs(result.value)) } + is Outcome.Err -> { AppLog.api("dialog", "getSavedGifs ${result.telegramError}") if (snapshot().savedGifs.isEmpty()) { @@ -91,28 +89,27 @@ internal fun DialogExecutor.openEmojiTab(tab: String) { emit(Msg.EmojiTab(tab)) emit(Msg.GifPicker(tab == ComposerPanels.TAB_GIFS)) when (tab) { - ComposerPanels.TAB_GIFS -> - if (!snapshot().savedGifsLoaded || snapshot().savedGifsError) loadSavedGifs() - ComposerPanels.TAB_STICKERS -> - if (snapshot().stickerSets.isEmpty() || snapshot().stickerSetsError) { - loadStickerCatalog(emoji = false) - } - ComposerPanels.TAB_EMOJI -> - if (snapshot().emojiSets.isEmpty() || snapshot().emojiSetsError) { - loadStickerCatalog(emoji = true) - } + ComposerPanels.TAB_GIFS -> if (!snapshot().savedGifsLoaded || snapshot().savedGifsError) loadSavedGifs() + + ComposerPanels.TAB_STICKERS -> if (snapshot().stickerSets.isEmpty() || snapshot().stickerSetsError) { + loadStickerCatalog(emoji = false) + } + + ComposerPanels.TAB_EMOJI -> if (snapshot().emojiSets.isEmpty() || snapshot().emojiSetsError) { + loadStickerCatalog(emoji = true) + } } } internal fun DialogExecutor.loadStickerCatalog(emoji: Boolean) { - StickerCatalogMemory.get(emoji)?.let { cached -> + memory.catalogs.get(emoji)?.let { cached -> if (emoji) emit(Msg.EmojiSets(cached.sets)) else emit(Msg.StickerSets(cached.sets)) - if (!StickerCatalogMemory.isLive(emoji)) refreshStickerCatalog(emoji, cached.hash) + if (!memory.catalogs.isLive(emoji)) refreshStickerCatalog(emoji, cached.hash) return } work.launch { sessionStore?.let { PickerDisk.readCatalog(it, emoji) }?.let { cached -> - StickerCatalogMemory.put(emoji, cached) + memory.catalogs.put(emoji, cached) if (emoji) emit(Msg.EmojiSets(cached.sets)) else emit(Msg.StickerSets(cached.sets)) refreshStickerCatalog(emoji, cached.hash) return@launch @@ -126,14 +123,14 @@ internal fun DialogExecutor.refreshStickerCatalog(emoji: Boolean, hash: Long) { val result = if (emoji) client.getEmojiStickers(hash) else client.getAllStickers(hash) when (result) { is Outcome.Ok -> { - StickerCatalogMemory.markLive(emoji) + memory.catalogs.markLive(emoji) if (result.value.notModified) { - val cached = StickerCatalogMemory.get(emoji)?.sets.orEmpty() + val cached = memory.catalogs.get(emoji)?.sets.orEmpty() if (emoji) emit(Msg.EmojiSets(cached)) else emit(Msg.StickerSets(cached)) return@launch } val snapshot = StickerCatalogSnapshot(result.value.hash, result.value.sets) - StickerCatalogMemory.put(emoji, snapshot) + memory.catalogs.put(emoji, snapshot) sessionStore?.let { PickerDisk.writeCatalog(it, emoji, snapshot) } if (emoji) { emit(Msg.EmojiSets(result.value.sets)) @@ -141,9 +138,11 @@ internal fun DialogExecutor.refreshStickerCatalog(emoji: Boolean, hash: Long) { emit(Msg.StickerSets(result.value.sets)) } } + is Outcome.Err -> { handleError(result.telegramError, false) - val empty = if (emoji) snapshot().emojiSets.isEmpty() else snapshot().stickerSets.isEmpty() + val empty = + if (emoji) snapshot().emojiSets.isEmpty() else snapshot().stickerSets.isEmpty() if (empty) { if (emoji) { emit(Msg.EmojiSets(emptyList(), error = true)) @@ -157,11 +156,11 @@ internal fun DialogExecutor.refreshStickerCatalog(emoji: Boolean, hash: Long) { } internal fun DialogExecutor.loadStickerPack(setId: Long, accessHash: Long) { - val cached = StickerPackMemory.get(setId) ?: snapshot().loadedStickerPacks[setId] + val cached = memory.packs.get(setId) ?: snapshot().loadedStickerPacks[setId] val hasPreviews = cached != null && cached.previewDocumentIds.isNotEmpty() // A process restart keeps preview ids on disk but drops the native document // locations. Show the cached pack, then still fetch it once so downloads work. - if (cached != null && hasPreviews && StickerPackMemory.isNativeReady(setId)) { + if (cached != null && hasPreviews && memory.packs.isNativeReady(setId)) { emit(Msg.StickerPackLoaded(cached)) return } @@ -174,7 +173,7 @@ internal fun DialogExecutor.loadStickerPack(setId: Long, accessHash: Long) { val store = sessionStore val disk = store?.let { PickerDisk.readPack(it, setId) } if (disk != null) { - val dropped = StickerPackMemory.put(disk) + val dropped = memory.packs.put(disk) if (dropped != null) PickerDisk.deletePack(store, dropped) emit(Msg.StickerPackLoaded(disk)) known = disk @@ -184,9 +183,9 @@ internal fun DialogExecutor.loadStickerPack(setId: Long, accessHash: Long) { if (already != null) emit(Msg.StickerPackLoaded(already)) } val shown = known - if (shown != null && - shown.previewDocumentIds.isNotEmpty() && - StickerPackMemory.isNativeReady(setId) + if (shown != null && shown.previewDocumentIds.isNotEmpty() && memory.packs.isNativeReady( + setId + ) ) { return@launch } @@ -194,14 +193,15 @@ internal fun DialogExecutor.loadStickerPack(setId: Long, accessHash: Long) { try { when (val result = client.getStickerSet(setId, accessHash)) { is Outcome.Ok -> { - val dropped = StickerPackMemory.put(result.value) - StickerPackMemory.markNativeReady(setId) + val dropped = memory.packs.put(result.value) + memory.packs.markNativeReady(setId) sessionStore?.let { if (dropped != null) PickerDisk.deletePack(it, dropped) PickerDisk.writePack(it, result.value) } emit(Msg.StickerPackLoaded(result.value)) } + is Outcome.Err -> { handleError(result.telegramError, false) if (shown == null || shown.previewDocumentIds.isEmpty()) { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AudioMessagePlayback.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AudioMessagePlayback.kt index 51815802b..3a5b3485f 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AudioMessagePlayback.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AudioMessagePlayback.kt @@ -1,9 +1,15 @@ package org.monogram.feature.dialog.ui +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -14,6 +20,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.core.common.Outcome import org.monogram.core.models.Message +import org.monogram.core.ui.media.MediaPlaybackHolder import org.monogram.core.ui.media.MediaPlaybackSession import org.monogram.core.ui.media.MediaSource import org.monogram.core.ui.media.MediaSurface @@ -23,19 +30,35 @@ import org.monogram.network.http.MediaPriority import org.monogram.network.http.MediaRepository import java.io.File -internal val LocalAudioMessagePlayback = androidx.compose.runtime.staticCompositionLocalOf { null } +internal val LocalAudioMessagePlayback = staticCompositionLocalOf { null } -@androidx.compose.runtime.Composable -fun AudioMessagePlaybackScope(enabled: Boolean = true, content: @androidx.compose.runtime.Composable (() -> Unit) -> Unit) { - val context = androidx.compose.ui.platform.LocalContext.current - val session = androidx.compose.runtime.remember(context) { org.monogram.core.ui.media.MediaPlaybackHolder.session(context) } - val playback = androidx.compose.runtime.remember(session) { AudioPlaybackHolder.get(session) } - androidx.compose.runtime.LaunchedEffect(enabled) { if (!enabled) { playback.clear(); session.stop() } } - androidx.compose.runtime.CompositionLocalProvider(LocalAudioMessagePlayback provides playback) { content(playback::toggleCurrent) } +@Composable +fun AudioMessagePlaybackScope( + enabled: Boolean = true, + content: @Composable (() -> Unit) -> Unit, +) { + val context = LocalContext.current + val session = + remember(context) { + MediaPlaybackHolder + .session(context) + } + val playback = remember(session) { AudioPlaybackHolder.get(session) } + LaunchedEffect(enabled) { if (!enabled) playback.stop() } + CompositionLocalProvider(LocalAudioMessagePlayback provides playback) { content(playback::toggleCurrent) } +} + +fun stopAudioMessagePlayback() { + AudioPlaybackHolder.stop() } private object AudioPlaybackHolder { private var instance: AudioMessagePlayback? = null + + fun stop() { + instance?.stop() + } + fun get(session: MediaPlaybackSession): AudioMessagePlayback = instance ?: AudioMessagePlayback(session).also { instance = it } } @@ -60,19 +83,26 @@ internal class AudioMessagePlayback( scope.launch { snapshotFlow { session.current?.id }.collectLatest { id -> if (pendingId != null && pendingId != id) cancelRequest() - val retainedId = retryMessage?.let { "audio:" + it.id.chatId.value + ":" + it.id.id } + val retainedId = + retryMessage?.let { "audio:" + it.id.chatId.value + ":" + it.id.id } if (retainedId != null && retainedId != id) clear() } } } - fun play(message: Message, repo: MediaRepository?, file: File?, title: String) { + fun play( + message: Message, + repo: MediaRepository?, + file: File?, + title: String, + ) { val id = "audio:" + message.id.chatId.value + ":" + message.id.id - if (pendingId == id) { + val revision = "${repo?.cacheRoot?.absolutePath}:${message.mediaCacheKey}" + if (pendingId == id && session.current?.contentRevision == revision) { cancel(id) return } - if (session.current?.id == id && session.current?.source != null) { + if (session.current?.id == id && session.current?.contentRevision == revision && session.current?.source != null) { session.togglePlayPause() return } @@ -81,23 +111,34 @@ internal class AudioMessagePlayback( retryMessage = message retryRepository = repo retryTitle = title - val descriptor = MediaViewerItem( - id = id, - kind = if (message.mediaKind == "voice") MediaViewerKind.VOICE else MediaViewerKind.AUDIO, - source = file?.let { - MediaSource.Local( - it, - message.mediaCacheKey?.let { key -> repo?.createCachedDataSourceFactory(key) }) - }, - fileName = title, - senderName = message.senderName, - durationSeconds = message.mediaDuration, - protectedContent = message.noforwards, - sourceChatId = message.id.chatId.value, - sourceMessageId = message.id.id, - loading = file == null, - ) - if (file == null && repo == null) { failedId = id; return } + val descriptor = + MediaViewerItem( + id = id, + contentRevision = revision, + kind = if (message.mediaKind == "voice") MediaViewerKind.VOICE else MediaViewerKind.AUDIO, + source = + file?.let { + MediaSource.Local( + it, + message.mediaCacheKey?.let { key -> + repo?.createCachedDataSourceFactory( + key + ) + }, + ) + }, + fileName = title, + senderName = message.senderName, + durationSeconds = message.mediaDuration, + protectedContent = message.noforwards, + sourceChatId = message.id.chatId.value, + sourceMessageId = message.id.id, + loading = file == null, + ) + if (file == null && repo == null) { + failedId = id + return + } session.setQueue(listOf(descriptor), 0, autoplay = file != null, startMuted = false) session.attachSurface(MediaSurface.AUDIO_ONLY) if (file != null) { @@ -109,42 +150,60 @@ internal class AudioMessagePlayback( repository = repo cacheKey = message.mediaCacheKey val token = generation - request = scope.launch { - val result = try { - withContext(Dispatchers.IO) { checkNotNull(repo).ensureLocalMessageMedia(message, MediaPriority.USER) } - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - Outcome.Err("audio download failed") - } - if (generation != token || session.current?.id != id) return@launch - pendingId = null - request = null - when (result) { - is Outcome.Ok -> { - repository = null - cacheKey = null - clearRetry() - session.setQueue( - listOf( - descriptor.copy( - source = MediaSource.Local( - result.value, - message.mediaCacheKey?.let { - checkNotNull(repo).createCachedDataSourceFactory(it) - }), loading = false + request = + scope.launch { + val result = + try { + withContext(Dispatchers.IO) { + checkNotNull(repo).ensureLocalMessageMedia( + message, + MediaPriority.USER ) - ), 0, - autoplay = true, startMuted = false) - session.play() - } - is Outcome.Err -> { - failedId = id - session.setQueue(listOf(descriptor.copy(loading = false, failed = true)), 0, - autoplay = false, startMuted = false) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + Outcome.Err("audio download failed") + } + if (generation != token || session.current?.id != id) return@launch + pendingId = null + request = null + when (result) { + is Outcome.Ok -> { + repository = null + cacheKey = null + clearRetry() + session.setQueue( + listOf( + descriptor.copy( + source = + MediaSource.Local( + result.value, + message.mediaCacheKey?.let { + checkNotNull(repo).createCachedDataSourceFactory(it) + }, + ), + loading = false, + ), + ), + 0, + autoplay = true, + startMuted = false, + ) + session.play() + } + + is Outcome.Err -> { + failedId = id + session.setQueue( + listOf(descriptor.copy(loading = false, failed = true)), + 0, + autoplay = false, + startMuted = false, + ) + } } } - } } fun cancel(id: String) { @@ -165,10 +224,13 @@ internal class AudioMessagePlayback( fun toggleCurrent() { val id = session.current?.id ?: return - if (pendingId == id) cancel(id) - else if (session.current?.source == null && failedId == id) { + if (pendingId == id) { + cancel(id) + } else if (session.current?.source == null && failedId == id) { retryMessage?.let { play(it, retryRepository, null, retryTitle) } - } else session.togglePlayPause() + } else { + session.togglePlayPause() + } } private fun clearRetry() { @@ -177,6 +239,11 @@ internal class AudioMessagePlayback( retryTitle = "" } + fun stop() { + clear() + session.stop() + } + fun clear() { cancelRequest() failedId = null diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ChatInlineVideo.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ChatInlineVideo.kt index 45ce4253e..a7bdcb7ae 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ChatInlineVideo.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ChatInlineVideo.kt @@ -52,9 +52,12 @@ internal fun ChatInlineVideo( LaunchedEffect(visible) { if (visible) onScreen = true } - val item = remember(mediaId, message.fileSize, message.mediaDuration, repository) { + val item = remember( + mediaId, message.mediaCacheKey, message.fileSize, message.mediaDuration, repository + ) { MediaViewerItem( id = mediaId, + contentRevision = "${repository.cacheRoot.absolutePath}:${message.mediaCacheKey}", kind = MediaViewerKind.VIDEO, source = MediaSource.Stream( uri = Uri.parse("telegram://media/${message.id.chatId.value}/${message.id.id}"), @@ -72,11 +75,7 @@ internal fun ChatInlineVideo( val surface = session.surface val currentId = session.current?.id val busyElsewhere = surface == MediaSurface.VIEWER || surface == MediaSurface.PIP - onScreen && autoplay && animationEnabled && !MediaScrollGate.isScrolling() && - !session.isMessagePlayback && - (!busyElsewhere || currentId == mediaId) && - surface != MediaSurface.VIEWER && - surface != MediaSurface.PIP + onScreen && autoplay && animationEnabled && !MediaScrollGate.isScrolling() && !session.isMessagePlayback && (!busyElsewhere || currentId == mediaId) && surface != MediaSurface.VIEWER && surface != MediaSurface.PIP }.distinctUntilChanged().collect { shouldPlay -> if (!shouldPlay) { if (session.current?.id == mediaId && session.surface == MediaSurface.CHAT) { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ComposerMediaPanels.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ComposerMediaPanels.kt index 34d6e73de..ce6c97b5a 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ComposerMediaPanels.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ComposerMediaPanels.kt @@ -151,8 +151,12 @@ internal fun EmojiStickerGifPanel( maxWidth = if (wide) 520.dp else Dp.Unspecified, ) { val context = LocalContext.current - val panelPool = remember { - PanelPlayerPool(File(context.filesDir, "panel-frames")) + val panelPool = remember(mediaRepository) { + PanelPlayerPool( + File( + mediaRepository?.cacheRoot ?: context.cacheDir, "panel-frames" + ) + ) } DisposableEffect(panelPool) { onDispose { panelPool.close() } @@ -161,9 +165,10 @@ internal fun EmojiStickerGifPanel( if (!sheetVisible) panelPool.clear() } val animations = panelAnimationsEnabled() - val lifecycleStarted = LocalLifecycleOwner.current.lifecycle.currentStateAsState() - .value - .isAtLeast(Lifecycle.State.STARTED) + val lifecycleStarted = + LocalLifecycleOwner.current.lifecycle.currentStateAsState().value.isAtLeast( + Lifecycle.State.STARTED + ) val epoch = panelEpoch( tab, when (tab) { @@ -340,13 +345,7 @@ private fun SystemEmojiBrowser( ?: openPack)) } val layout = remember( - recent, - categories, - packs, - resolvedPacks, - expanded, - loadingPackIds, - failedPackIds + recent, categories, packs, resolvedPacks, expanded, loadingPackIds, failedPackIds ) { emojiPanelLayout( recent = recent, @@ -538,9 +537,7 @@ private fun EmojiPanelGrid( val cells = layout.cells val documentIds = remember(cells) { cells.mapNotNull { it.documentId } } ReportPickerVisible( - itemsKey = documentIds, - gridState = gridState, - idle = ready + itemsKey = documentIds, gridState = gridState, idle = ready ) { min, max, idle -> onDocumentsVisible(documentIds, visibleDocumentIds(cells, min, max), idle) } @@ -589,9 +586,7 @@ private fun EmojiPanelGrid( is PickerExpandCell -> { val title = cells.filterIsInstance() - .firstOrNull { it.packId == cell.packId } - ?.title - .orEmpty() + .firstOrNull { it.packId == cell.packId }?.title.orEmpty() Box( modifier = Modifier .size(PickerMetrics.EmojiPackCell.dp) @@ -929,8 +924,7 @@ internal fun SavedGifBrowser( idle = ready, ) { min, max, settled -> val visible = gifs.subList(min.coerceAtLeast(0), (max + 1).coerceIn(0, gifs.size)) - .map { it.documentId } - .toSet() + .map { it.documentId }.toSet() onGifsVisible(gifs, visible, settled) } LazyVerticalGrid( diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogRenderCache.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogRenderCache.kt index 2fa8f271f..dbc0e2ebe 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogRenderCache.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogRenderCache.kt @@ -8,39 +8,50 @@ import org.monogram.core.models.TextEntity /** Keeps decoded chat chrome across LazyColumn recycle so scroll does not flash. */ internal object DialogRenderCache { private const val MAX = 48 - private val highlights = lru>(MAX) + + internal data class BlocksKey( + val text: String, + val entities: List, + val parseMarkdown: Boolean, + ) + + private val highlights = lru, List>(MAX) private val formulas = lru(MAX) - private val blocks = lru>(MAX) + private val blocks = lru>(MAX) /** * Parsed rich blocks survive LazyColumn recycle: native parsing is synchronous on the * main thread, so re-parsing on every scroll back is the visible cost. */ @Synchronized - fun blocks(key: String): List? = blocks[key] + fun blocks(key: BlocksKey): List? = blocks[key] @Synchronized - fun putBlocks(key: String, value: List) { + fun putBlocks( + key: BlocksKey, + value: List, + ) { blocks[key] = value } - fun blocksKey(text: String, entities: List, parseMarkdown: Boolean): String { - val sig = StringBuilder(text.length + entities.size * 24 + 8) - sig.append(if (parseMarkdown) '1' else '0').append('\u0000') - sig.append(text.length).append('\u0000').append(text.hashCode()) - entities.forEach { entity -> - sig.append('\u0001').append(entity.kind).append(',').append(entity.offset) - .append(',').append(entity.length).append(',').append(entity.url.orEmpty()) - } - return sig.toString() - } + fun blocksKey( + text: String, + entities: List, + parseMarkdown: Boolean, + ): BlocksKey = BlocksKey(text, entities.toList(), parseMarkdown) @Synchronized - fun highlight(code: String, language: String?): List? = - highlights[highlightKey(code, language)] + fun highlight( + code: String, + language: String?, + ): List? = highlights[highlightKey(code, language)] @Synchronized - fun putHighlight(code: String, language: String?, value: List) { + fun putHighlight( + code: String, + language: String?, + value: List, + ) { highlights[highlightKey(code, language)] = value } @@ -48,7 +59,10 @@ internal object DialogRenderCache { fun formula(key: String): ImageBitmap? = formulas[key] @Synchronized - fun putFormula(key: String, value: ImageBitmap) { + fun putFormula( + key: String, + value: ImageBitmap, + ) { formulas[key] = value } @@ -60,10 +74,14 @@ internal object DialogRenderCache { maxWidth: Int, ): String = "$source\u0000$display\u0000$colorArgb\u0000$fontSizePx\u0000$maxWidth" - private fun highlightKey(code: String, language: String?): String = - "${language.orEmpty()}\u0000${code.length}\u0000${code.hashCode()}" + private fun highlightKey( + code: String, + language: String?, + ): Pair = code to language - private fun lru(max: Int) = object : LinkedHashMap(max, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > max - } + private fun lru(max: Int) = + object : LinkedHashMap(max, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > max + } } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogTopBar.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogTopBar.kt index 579bcef7b..8ef75e2c3 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogTopBar.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogTopBar.kt @@ -82,7 +82,7 @@ internal fun DialogTopBar( onDeleteSelectedMessages: () -> Unit, ) { val hasScheduledMessages by component.hasScheduledMessages.collectAsStateWithLifecycle( - initialValue = false + initialValue = false, ) if (selectedMessageCount > 0) { TopAppBar( @@ -158,8 +158,11 @@ internal fun DialogTopBar( generation = cacheGeneration, identity = state.chatId.value to state.photoCacheKey, resolve = { - state.photoCacheKey?.let { component.mediaRepository?.cachedFile(it) } - ?: component.mediaRepository?.cachedAvatar(state.chatId) + state.photoCacheKey?.let { + component.mediaRepository?.cachedFile( + it, + ) + } }, ensure = { val repo = @@ -168,13 +171,11 @@ internal fun DialogTopBar( state.chatId, state.photoCacheKey, ) - when ( - val result = repo.ensureLocalAvatar( - state.chatId, - key, - priority = MediaPriority.VISIBLE, - ) - ) { + when (val result = repo.ensureLocalAvatar( + state.chatId, + key, + priority = MediaPriority.VISIBLE, + )) { is Outcome.Ok -> result.value is Outcome.Err -> null } @@ -194,8 +195,17 @@ internal fun DialogTopBar( title = title, size = 38.dp, imageFile = chatAvatar, - previewBytes = rememberStrippedPreview(state.chatId) { - component.mediaRepository?.peerInlineThumbJpeg(state.chatId) + previewBytes = rememberStrippedPreview( + Triple( + mediaRepository, + state.chatId, + state.photoCacheKey, + ), + ) { + component.mediaRepository?.peerInlineThumbJpeg( + state.chatId, + state.photoCacheKey, + ) }, ) } @@ -246,11 +256,13 @@ internal fun DialogTopBar( } }, navigationIcon = { - if (component.showBackButton) IconButton(onClick = component::onBack) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.dialog_back), - ) + if (component.showBackButton) { + IconButton(onClick = component::onBack) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.dialog_back), + ) + } } }, actions = { @@ -262,11 +274,13 @@ internal fun DialogTopBar( ) } } - if (hasScheduledMessages) IconButton(onClick = component::onOpenScheduledQueue) { - Icon( - Icons.Outlined.Schedule, - stringResource(R.string.dialog_scheduled_messages) - ) + if (hasScheduledMessages) { + IconButton(onClick = component::onOpenScheduledQueue) { + Icon( + Icons.Outlined.Schedule, + stringResource(R.string.dialog_scheduled_messages), + ) + } } IconButton( onClick = onToggleSearch, @@ -319,10 +333,8 @@ private fun DialogStatusLine( val presenceAt = if (enabled) live?.statusAt ?: statusAt else null val presenceNow = rememberPeerStatusNow(presenceStatus, presenceAt) OnlineLeaseExpiry(presenceStatus, presenceAt, onExpired) - val fallback = syncLabel - ?: peerStatusLabel(presenceStatus, presenceAt, presenceNow) - ?: stats - ?: when { + val fallback = + syncLabel ?: peerStatusLabel(presenceStatus, presenceAt, presenceNow) ?: stats ?: when { isChannel -> channelLabel isGroup -> groupLabel else -> null @@ -330,8 +342,7 @@ private fun DialogStatusLine( AnimatedContent( targetState = typing?.action to (typing?.names ?: emptyList()) to fallback, transitionSpec = { - val typingNow = targetState.first.first != null || - initialState.first.first != null + val typingNow = targetState.first.first != null || initialState.first.first != null if (typingNow) { liveActionTransition() } else { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ForwardPicker.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ForwardPicker.kt index a88617da9..842e7ab7a 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ForwardPicker.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ForwardPicker.kt @@ -50,8 +50,8 @@ fun ForwardPickerSheet( targets } else { targets.filter { - it.title.contains(q, ignoreCase = true) || - it.lastMessagePreview.orEmpty().contains(q, ignoreCase = true) + it.title.contains(q, ignoreCase = true) || it.lastMessagePreview.orEmpty() + .contains(q, ignoreCase = true) } } } @@ -65,51 +65,55 @@ fun ForwardPickerSheet( size = MonogramLoadingHeroSize, status = stringResource(R.string.dialog_forwarding), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - ) { - Text( - text = stringResource(R.string.dialog_forward_to), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - ) - MonogramBusyField( - value = query, - onValueChange = onQuery, + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - busy = forwarding, - singleLine = true, - shape = MaterialTheme.shapes.large, - label = stringResource(R.string.dialog_forward_search), - ) - if (shown.isEmpty()) { + .padding(bottom = 8.dp), + ) { Text( - text = stringResource(R.string.dialog_forward_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(24.dp), + text = stringResource(R.string.dialog_forward_to), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + MonogramBusyField( + value = query, + onValueChange = onQuery, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + busy = forwarding, + singleLine = true, + shape = MaterialTheme.shapes.large, + label = stringResource(R.string.dialog_forward_search), ) - } else { - LazyColumn( - modifier = Modifier.heightIn(max = 420.dp), - contentPadding = PaddingValues(vertical = 4.dp), - ) { - items(shown, key = { it.id.value }, contentType = { "forward-chat" }) { chat -> - ForwardChatRow( - chat = chat, - mediaRepository = mediaRepository, - enabled = !forwarding, - onClick = { onSelect(chat.id) }, - ) + if (shown.isEmpty()) { + Text( + text = stringResource(R.string.dialog_forward_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(24.dp), + ) + } else { + LazyColumn( + modifier = Modifier.heightIn(max = 420.dp), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + items( + shown, + key = { it.id.value }, + contentType = { "forward-chat" }, + ) { chat -> + ForwardChatRow( + chat = chat, + mediaRepository = mediaRepository, + enabled = !forwarding, + onClick = { onSelect(chat.id) }, + ) + } } } } } - } } } @@ -126,7 +130,7 @@ private fun ForwardChatRow( generation = cacheGeneration, identity = chat.id.value to key, resolve = { - mediaRepository?.cachedFile(key) ?: mediaRepository?.cachedAvatar(chat.id) + mediaRepository?.cachedFile(key) }, ensure = { val repo = mediaRepository ?: return@rememberEnsuredFile null @@ -148,8 +152,8 @@ private fun ForwardChatRow( title = chat.title, size = 44.dp, imageFile = avatarFile, - previewBytes = rememberStrippedPreview(chat.id) { - mediaRepository?.peerInlineThumbJpeg(chat.id) + previewBytes = rememberStrippedPreview(Triple(mediaRepository, chat.id, key)) { + mediaRepository?.peerInlineThumbJpeg(chat.id, key) }, ) Column(modifier = Modifier.weight(1f)) { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MentionOverlay.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MentionOverlay.kt index 2644ab68e..38d34ce8e 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MentionOverlay.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MentionOverlay.kt @@ -75,14 +75,18 @@ internal fun MentionOverlayHost( AnimatedVisibility( visible = visible, modifier = modifier.fillMaxWidth(), - enter = fadeIn(motion.defaultEffectsSpec()) + expandVertically( - animationSpec = motion.defaultSpatialSpec(), - expandFrom = Alignment.Bottom, - ), - exit = fadeOut(motion.fastEffectsSpec()) + shrinkVertically( - animationSpec = motion.fastSpatialSpec(), - shrinkTowards = Alignment.Bottom, - ), + enter = + fadeIn(motion.defaultEffectsSpec()) + + expandVertically( + animationSpec = motion.defaultSpatialSpec(), + expandFrom = Alignment.Bottom, + ), + exit = + fadeOut(motion.fastEffectsSpec()) + + shrinkVertically( + animationSpec = motion.fastSpatialSpec(), + shrinkTowards = Alignment.Bottom, + ), ) { MentionOverlay( handle = handle, @@ -109,46 +113,59 @@ internal fun MentionOverlay( modifier: Modifier = Modifier, ) { Surface( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 240.dp, max = composerPanelHeight()), + modifier = + modifier + .fillMaxWidth() + .heightIn(min = 240.dp, max = composerPanelHeight()), color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), tonalElevation = 3.dp, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp, bottom = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 8.dp), ) { Text( - text = if (handle.isBlank()) { - stringResource(R.string.dialog_mentions) - } else { - "@$handle" - }, + text = + if (handle.isBlank()) { + stringResource(R.string.dialog_mentions) + } else { + "@$handle" + }, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), ) when { - loading && candidates.isEmpty() -> MonogramLoading( - modifier = Modifier - .padding(20.dp) - .align(Alignment.CenterHorizontally), - size = MonogramLoadingListSize, - ) - candidates.isEmpty() -> Text( - text = stringResource(R.string.dialog_mention_empty), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + loading && candidates.isEmpty() -> { + MonogramLoading( + modifier = + Modifier + .padding(20.dp) + .align(Alignment.CenterHorizontally), + size = MonogramLoadingListSize, + ) + } + + candidates.isEmpty() -> { + Text( + text = stringResource(R.string.dialog_mention_empty), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + else -> { val listState = rememberLazyListState() val shouldLoadMore by remember(hasMore, loading, candidates.size) { derivedStateOf { - val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + val last = + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index ?: -1 hasMore && !loading && candidates.isNotEmpty() && last >= candidates.size - 6 } @@ -158,9 +175,10 @@ internal fun MentionOverlay( } LazyColumn( state = listState, - modifier = Modifier - .fillMaxWidth() - .heightIn(max = composerPanelHeight() - 48.dp), + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = composerPanelHeight() - 48.dp), ) { items( candidates, @@ -177,9 +195,10 @@ internal fun MentionOverlay( if (loading) { item(key = "mention-loading", contentType = "mention-status") { MonogramLoading( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), size = MonogramLoadingListSize, ) } @@ -201,53 +220,57 @@ private fun MentionRow( ) { val interaction = remember { MutableInteractionSource() } val pressed = interaction.collectIsPressedAsState().value - val corner = animateDpAsState( - targetValue = if (pressed) ExpressiveDefaults.PressRadius else 24.dp, - animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), - label = "mentionPress", - ) + val corner = + animateDpAsState( + targetValue = if (pressed) ExpressiveDefaults.PressRadius else 24.dp, + animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), + label = "mentionPress", + ) val label = stringResource(R.string.dialog_mention_user, candidate.title) val generation = rememberCacheGeneration(mediaRepository?.cacheGeneration) val key = peerAvatarCacheKey(candidate.peerId, candidate.avatarCacheKey) - val avatarFile = rememberEnsuredFile( - generation = generation, - identity = candidate.peerId.value to key, - resolve = { - mediaRepository?.cachedFile(key) ?: mediaRepository?.cachedAvatar(candidate.peerId) - }, - ensure = { - val repo = mediaRepository ?: return@rememberEnsuredFile null - when ( - val result = repo.ensureLocalAvatar( - candidate.peerId, - key, - MediaPriority.VISIBLE, - ) - ) { - is Outcome.Ok -> result.value - is Outcome.Err -> repo.cachedAvatar(candidate.peerId) - } - }, - ) + val avatarFile = + rememberEnsuredFile( + generation = generation, + identity = candidate.peerId.value to key, + resolve = { + mediaRepository?.cachedFile(key) + }, + ensure = { + val repo = mediaRepository ?: return@rememberEnsuredFile null + when ( + val result = + repo.ensureLocalAvatar( + candidate.peerId, + key, + MediaPriority.VISIBLE, + ) + ) { + is Outcome.Ok -> result.value + is Outcome.Err -> repo.cachedAvatar(candidate.peerId) + } + }, + ) Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(corner.value)) - .background( - if (pressed) { - MaterialTheme.colorScheme.surfaceContainerHighest - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - }, - ) - .clickable( - interactionSource = interaction, - indication = ripple(), - onClickLabel = label, - onClick = { onSelect(candidate) }, - ) - .padding(horizontal = 12.dp, vertical = 10.dp), + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(corner.value)) + .background( + if (pressed) { + MaterialTheme.colorScheme.surfaceContainerHighest + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + ) + .clickable( + interactionSource = interaction, + indication = ripple(), + onClickLabel = label, + onClick = { onSelect(candidate) }, + ) + .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(modifier = Modifier.size(44.dp)) { @@ -255,17 +278,19 @@ private fun MentionRow( title = candidate.title, size = 44.dp, imageFile = avatarFile, - previewBytes = rememberStrippedPreview(candidate.peerId) { - mediaRepository?.peerInlineThumbJpeg(candidate.peerId) - }, + previewBytes = + rememberStrippedPreview(Triple(mediaRepository, candidate.peerId, key)) { + mediaRepository?.peerInlineThumbJpeg(candidate.peerId, key) + }, ) if (candidate.isBot) { Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .size(16.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.tertiaryContainer), + modifier = + Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiaryContainer), contentAlignment = Alignment.Center, ) { Icon( diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageBubble.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageBubble.kt index 99307bbc8..0f5c32a4f 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageBubble.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageBubble.kt @@ -1,20 +1,14 @@ package org.monogram.feature.dialog.ui +import android.text.format.DateFormat.getTimeFormat import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.core.tween import androidx.compose.animation.animateContentSize -import androidx.compose.material.icons.outlined.MoreVert -import org.monogram.core.ui.menu.AppMenuItem -import org.monogram.core.ui.menu.AppMenuPopup -import org.monogram.core.ui.menu.AppMenuSurface - -import android.text.format.DateFormat.getTimeFormat import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -41,6 +35,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -75,7 +70,6 @@ import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints @@ -89,6 +83,9 @@ import org.monogram.core.models.ReplyMarkupKind import org.monogram.core.models.peerAvatarCacheKey import org.monogram.core.ui.components.PeerAvatar import org.monogram.core.ui.localizedServiceMessage +import org.monogram.core.ui.menu.AppMenuItem +import org.monogram.core.ui.menu.AppMenuPopup +import org.monogram.core.ui.menu.AppMenuSurface import org.monogram.core.ui.rememberEnsuredFile import org.monogram.core.ui.rememberStrippedPreview import org.monogram.feature.dialog.R @@ -100,7 +97,7 @@ import java.util.Locale @OptIn( ExperimentalFoundationApi::class, - androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class + androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class, ) @Composable fun MessageBubble( @@ -241,7 +238,7 @@ fun MessageBubble( bubbleEdgeMediaDisplaySize( message.mediaKind, message.mediaWidth, - message.mediaHeight + message.mediaHeight, ).first.dp } else { null @@ -259,36 +256,46 @@ fun MessageBubble( } else { MaterialTheme.colorScheme.onSurface } - val cacheGeneration = mediaRepository?.cacheGeneration?.collectAsState()?.value ?: 0L - val senderAvatar = rememberEnsuredFile( - generation = cacheGeneration, - identity = sender?.id?.value to sender?.avatarCacheKey, - resolve = { - sender?.avatarCacheKey?.let { mediaRepository?.cachedFile(it) } - ?: sender?.id?.let { mediaRepository?.cachedAvatar(it) } - }, - ensure = { - val repo = mediaRepository ?: return@rememberEnsuredFile null - val peer = sender?.id ?: return@rememberEnsuredFile null - val key = peerAvatarCacheKey(peer, sender.avatarCacheKey) - when (val result = repo.ensureLocalAvatar(peer, key)) { - is Outcome.Ok -> result.value - is Outcome.Err -> null - } - }, - ) Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = if (outgoing) Arrangement.End else Arrangement.Start, verticalAlignment = Alignment.Bottom, ) { if (!outgoing && showAvatar) { + val cacheGeneration = mediaRepository?.cacheGeneration?.collectAsState()?.value ?: 0L + val senderAvatar = rememberEnsuredFile( + generation = cacheGeneration, + identity = sender?.id?.value to sender?.avatarCacheKey, + resolve = { + sender?.avatarCacheKey?.let { mediaRepository?.cachedFile(it) } + }, + ensure = { + val repo = mediaRepository ?: return@rememberEnsuredFile null + val peer = sender?.id ?: return@rememberEnsuredFile null + val key = peerAvatarCacheKey(peer, sender.avatarCacheKey) + when (val result = repo.ensureLocalAvatar(peer, key)) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + }, + ) PeerAvatar( title = senderTitle.orEmpty(), size = 28.dp, imageFile = senderAvatar, - previewBytes = rememberStrippedPreview(sender?.id) { - sender?.id?.let { mediaRepository?.peerInlineThumbJpeg(it) } + previewBytes = rememberStrippedPreview( + Triple( + mediaRepository, + sender?.id, + sender?.avatarCacheKey, + ), + ) { + sender?.id?.let { + mediaRepository?.peerInlineThumbJpeg( + it, + sender.avatarCacheKey, + ) + } }, modifier = Modifier .padding(end = 6.dp, bottom = 2.dp) @@ -335,8 +342,7 @@ fun MessageBubble( if (!selectable && !passClicks && onOpenMenu != null) { val openMenu = rememberUpdatedState(onOpenMenu) val hold = rememberUpdatedState(onLongPress) - Modifier - .onGloballyPositioned { bubblePosition = it.positionInWindow() } + Modifier.onGloballyPositioned { bubblePosition = it.positionInWindow() } .pointerInput(hasSpoiler) { if (hasSpoiler) { menuOrHold( @@ -346,7 +352,7 @@ fun MessageBubble( }, onLongPress = { hold.value?.invoke() ?: openMenu.value.invoke( - bubblePosition + bubblePosition, ) }, ) @@ -355,7 +361,7 @@ fun MessageBubble( onTap = { openMenu.value.invoke(bubblePosition + it) }, onLongPress = { hold.value?.invoke() ?: openMenu.value.invoke( - bubblePosition + bubblePosition, ) }, ) @@ -414,7 +420,8 @@ fun MessageBubble( onClick = onOpenStickerPack?.let { open -> { open( - documentId, true + documentId, + true, ) } }, @@ -440,10 +447,11 @@ fun MessageBubble( modifier = Modifier .then( if (onOpenForwardSource != null) { - Modifier - .heightIn(min = 48.dp) + Modifier.heightIn(min = 48.dp) .clickable(onClick = onOpenForwardSource) - } else Modifier, + } else { + Modifier + }, ) .padding(vertical = 3.dp, horizontal = edgeContentPad), verticalArrangement = Arrangement.spacedBy(2.dp), @@ -464,7 +472,7 @@ fun MessageBubble( remember(date, locale, context.resources.configuration) { val original = java.util.Date(date * 1_000L) getDateInstance(java.text.DateFormat.MEDIUM, locale).format( - original + original, ) + " " + getTimeFormat(context).format(original) } Text( @@ -509,7 +517,7 @@ fun MessageBubble( onLongPress = onLongPress ?: onOpenMenu?.let { open -> { open( - bubblePosition + bubblePosition, ) } }, @@ -544,7 +552,7 @@ fun MessageBubble( onLongPress = onLongPress ?: onOpenMenu?.let { open -> { open( - bubblePosition + bubblePosition, ) } }, @@ -561,7 +569,8 @@ fun MessageBubble( onPollVote = onPollVote?.let { vote -> { options -> vote( - message.id.id, options + message.id.id, + options, ) } }, @@ -582,7 +591,7 @@ fun MessageBubble( onLongPress = onLongPress ?: onOpenMenu?.let { open -> { open( - bubblePosition + bubblePosition, ) } }, @@ -642,10 +651,10 @@ fun MessageBubble( stringResource( R.string.dialog_checklist_progress, completed, - list.items.size + list.items.size, ), style = MaterialTheme.typography.labelMedium, - color = onContainer.copy(alpha = 0.72f) + color = onContainer.copy(alpha = 0.72f), ) androidx.compose.material3.LinearProgressIndicator( progress = { completion }, @@ -654,14 +663,16 @@ fun MessageBubble( .padding(vertical = 4.dp), drawStopIndicator = {}, ) - if (checklistBusy) org.monogram.core.ui.loading.MonogramLoading() + if (checklistBusy) { + org.monogram.core.ui.loading.MonogramLoading() + } val addEnabled = onAddChecklistItem != null && (message.outgoing || list.othersCanAppend) && !message.pending && message.id.id > 0 && list.items.none { it.id == Int.MAX_VALUE } list.items.forEach { item -> androidx.compose.runtime.key(item.id) { val itemColor by androidx.compose.animation.animateColorAsState( if (item.done) onContainer.copy(alpha = 0.65f) else onContainer, - label = "taskColor" + label = "taskColor", ) val enabled = onToggleChecklist != null && !checklistBusy && !message.pending && message.id.id > 0 && (message.outgoing || list.othersCanComplete) @@ -673,7 +684,8 @@ fun MessageBubble( value = item.done, enabled = enabled, role = androidx.compose.ui.semantics.Role.Checkbox, - onValueChange = { onToggleChecklist?.invoke(item.id) }), + onValueChange = { onToggleChecklist?.invoke(item.id) }, + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -686,7 +698,7 @@ fun MessageBubble( text = item.text, entities = item.entities, textStyle = MaterialTheme.typography.bodyMedium.copy( - textDecoration = if (item.done) androidx.compose.ui.text.style.TextDecoration.LineThrough else null + textDecoration = if (item.done) androidx.compose.ui.text.style.TextDecoration.LineThrough else null, ), contentColor = itemColor, linkColor = MaterialTheme.colorScheme.primary, @@ -701,7 +713,7 @@ fun MessageBubble( val canEdit = canManage && onEditChecklistItem != null var confirmRemoval by rememberSaveable( message.id, - item.id + item.id, ) { mutableStateOf(false) } if (canRemove || canEdit) { var menuOpen by remember { mutableStateOf(false) } @@ -709,78 +721,85 @@ fun MessageBubble( IconButton( onClick = { menuOpen = true }, enabled = !checklistBusy, - shapes = IconButtonDefaults.shapes() + shapes = IconButtonDefaults.shapes(), ) { Icon( Icons.Outlined.MoreVert, contentDescription = stringResource( - org.monogram.core.ui.R.string.media_more_actions - ) + org.monogram.core.ui.R.string.media_more_actions, + ), ) } AppMenuPopup( menuOpen, { menuOpen = false }, alignToAnchorEnd = true, - scrim = true + scrim = true, ) { AppMenuSurface { Column { - if (canEdit) AppMenuItem( - text = stringResource( - R.string.dialog_checklist_edit_item, - item.text - ), - icon = Icons.Outlined.Edit, - onClick = { - menuOpen = - false; onEditChecklistItem?.invoke( - item.id + if (canEdit) { + AppMenuItem( + text = stringResource( + R.string.dialog_checklist_edit_item, + item.text, + ), + icon = Icons.Outlined.Edit, + onClick = { + menuOpen = false + onEditChecklistItem?.invoke( + item.id, + ) + }, ) - }, - ) - if (canRemove) AppMenuItem( - text = stringResource( - R.string.dialog_checklist_remove_item, - item.text - ), - icon = Icons.Outlined.Delete, - onClick = { - menuOpen = false; confirmRemoval = - true - }, - ) + } + if (canRemove) { + AppMenuItem( + text = stringResource( + R.string.dialog_checklist_remove_item, + item.text, + ), + icon = Icons.Outlined.Delete, + onClick = { + menuOpen = false + confirmRemoval = true + }, + ) + } } } } } } - if (confirmRemoval && canRemove) androidx.compose.material3.AlertDialog( - onDismissRequest = { confirmRemoval = false }, - text = { - Text( - stringResource( - R.string.dialog_checklist_remove_confirm, - item.text + if (confirmRemoval && canRemove) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { confirmRemoval = false }, + text = { + Text( + stringResource( + R.string.dialog_checklist_remove_confirm, + item.text, + ), ) - ) - }, - confirmButton = { - androidx.compose.material3.TextButton( - enabled = !checklistBusy, - onClick = { - confirmRemoval = - false; onRemoveChecklistItem?.invoke(item.id) - }) { Text(stringResource(R.string.dialog_delete)) } - }, - dismissButton = { - androidx.compose.material3.TextButton(onClick = { - confirmRemoval = false - }) { - Text(stringResource(R.string.dialog_cancel)) - } - }, - ) + }, + confirmButton = { + androidx.compose.material3.TextButton( + enabled = !checklistBusy, + onClick = { + confirmRemoval = false + onRemoveChecklistItem?.invoke(item.id) + }, + ) { Text(stringResource(R.string.dialog_delete)) } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = { + confirmRemoval = false + }) { + Text(stringResource(R.string.dialog_cancel)) + } + }, + ) + } } } } @@ -794,15 +813,18 @@ fun MessageBubble( .clickable(enabled = !checklistBusy) { val nextId = (list.items.maxOfOrNull { it.id } ?: 0).toLong() + 1 - if (nextId <= Int.MAX_VALUE) onAddChecklistItem.invoke( - message.id.id, nextId.toInt() - ) + if (nextId <= Int.MAX_VALUE) { + onAddChecklistItem.invoke( + message.id.id, + nextId.toInt(), + ) + } } .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy( 8.dp, - Alignment.CenterHorizontally + Alignment.CenterHorizontally, ), ) { Icon( @@ -829,7 +851,10 @@ fun MessageBubble( ) } if (mediaCaption && !mediaOverlayMeta && message.text != message.checklist?.title) { - var textLayoutInfo by remember { mutableStateOf(null) } + var textLayoutInfo by remember( + message.id, + renderedBlocks, + ) { mutableStateOf(null) } val hasWideChrome = message.replyToMsgId != null || !quote.isNullOrBlank() || message.mediaKind != null || showSender || !message.fwdFrom.isNullOrBlank() || !message.viaBot.isNullOrBlank() TextWithTimestampLayout( @@ -879,11 +904,11 @@ fun MessageBubble( visible = onRetrySend != null, enter = fadeIn(tween(160)) + expandVertically( tween(220), - expandFrom = Alignment.Top + expandFrom = Alignment.Top, ), exit = fadeOut(tween(100)) + shrinkVertically( tween(180), - shrinkTowards = Alignment.Top + shrinkTowards = Alignment.Top, ), ) { OutgoingBubbleFailure(sendUncertain, sendError) { onRetrySend?.invoke() } @@ -922,8 +947,7 @@ fun MessageBubble( if (hasComments) { Modifier.padding(start = 2.dp, end = 2.dp, top = 4.dp) } else { - Modifier - .offset(y = (-4).dp) + Modifier.offset(y = (-4).dp) .padding(start = 2.dp, end = 2.dp, top = 6.dp) }, ), @@ -969,7 +993,7 @@ internal fun Modifier.bleedBubbleMedia( val y = if (bleedTop) -vInset else 0 val height = (placeable.height - (if (bleedTop) vInset else 0) - (if (bleedBottom) vInset else 0)).coerceAtLeast( - 0 + 0, ) layout(constraints.maxWidth, height) { placeable.place(x, y) @@ -1045,8 +1069,13 @@ private suspend fun PointerInputScope.menuOrHold( onTap(down.position) } - released == false || claimed || down.isConsumed -> return@awaitEachGesture - else -> onLongPress() + released == false || claimed || down.isConsumed -> { + return@awaitEachGesture + } + + else -> { + onLongPress() + } } if (released != null) return@awaitEachGesture while (true) { @@ -1056,4 +1085,4 @@ private suspend fun PointerInputScope.menuOrHold( change.consume() } } -} \ No newline at end of file +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt index d45662c25..162c063e2 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt @@ -63,7 +63,10 @@ private val announcementKinds = setOf( "video_stream", ) -internal fun retainMediaFile(current: File?, fetched: File?): File? = fetched ?: current +internal fun retainMediaFile( + current: File?, + fetched: File?, +): File? = fetched ?: current @Composable fun MessageMedia( @@ -81,8 +84,8 @@ fun MessageMedia( RecompositionProbe("MessageMedia") val animationEnabled = LocalMediaAnimationEnabled.current if (kind == "unsupported" || kind in announcementKinds) return - val localFile = localOutgoingFile(message.mediaCacheKey) - ?: localOutgoingFile(message.thumbCacheKey) + val localFile = + localOutgoingFile(message.mediaCacheKey) ?: localOutgoingFile(message.thumbCacheKey) if (localFile != null) { OutgoingLocalMedia( message = message, @@ -98,8 +101,7 @@ fun MessageMedia( val preset = downloadState.presetFor(DownloadSettings.activeNetwork()) val fullKey = message.mediaCacheKey ?: return val thumbKey = message.thumbCacheKey ?: fullKey - val displayKey = if ( - shouldFetchDisplayPreview( + val displayKey = if (shouldFetchDisplayPreview( kind, false, message.fileSize, @@ -121,8 +123,8 @@ fun MessageMedia( } else { visualMediaDisplaySize(kind, message.mediaWidth, message.mediaHeight) } - val durationAtStart = edgeToEdge && - !shouldShowMessageCaption(message.mediaKind, message.text, message.fileName) + val durationAtStart = + edgeToEdge && !shouldShowMessageCaption(message.mediaKind, message.text, message.fileName) val frameW by animateDpAsState(photoSize.first.dp, tween(220), label = "mediaFrameW") val frameH by animateDpAsState(photoSize.second.dp, tween(220), label = "mediaFrameH") val stickerClick = if (sticker && onStickerClick != null) { @@ -154,18 +156,18 @@ fun MessageMedia( .then(visibilityModifier) } val strippedJpeg = rememberStrippedPreview( - identity = thumbKey, + identity = mediaRepository to thumbKey, demand = mediaVisible, ) { mediaRepository?.inlineThumbJpeg(message) } - var thumbFile by remember(thumbKey) { + var thumbFile by remember(mediaRepository, thumbKey) { mutableStateOf(mediaRepository?.cachedFile(thumbKey)) } - var displayFile by remember(displayKey) { + var displayFile by remember(mediaRepository, displayKey) { mutableStateOf(displayKey?.let { mediaRepository?.cachedFile(it) }) } - var fullFile by remember(fullKey) { + var fullFile by remember(mediaRepository, fullKey) { mutableStateOf(mediaRepository?.cachedFile(fullKey)) } val cacheGenerationFlow = remember(mediaRepository, thumbKey, displayKey, fullKey) { @@ -177,40 +179,44 @@ fun MessageMedia( val albumMessages = LocalAlbumMessages.current var playing by remember(fullKey) { mutableStateOf( - kind == "gif" && - downloadState.autoplayGifs && - preset.allowsFull("gif", message.fileSize), + kind == "gif" && downloadState.autoplayGifs && preset.allowsFull( + "gif", message.fileSize + ), ) } val mediaScope = rememberCoroutineScope() var wantFull by remember(fullKey) { mutableStateOf(false) } var downloadAttempt by remember(fullKey) { mutableStateOf(0) } val userOwned by remember(mediaRepository, fullKey) { - mediaRepository?.userDownloads - ?.map { fullKey in it } - ?.distinctUntilChanged() - ?: flowOf(false) + mediaRepository?.userDownloads?.map { fullKey in it }?.distinctUntilChanged() ?: flowOf( + false + ) }.collectAsStateWithLifecycle( initialValue = mediaRepository?.isUserDownload(fullKey) == true, ) - val pendingPlayback = LocalAudioMessagePlayback.current?.pendingId == - "audio:" + message.id.chatId.value + ":" + message.id.id + val pendingPlayback = + LocalAudioMessagePlayback.current?.pendingId == "audio:" + message.id.chatId.value + ":" + message.id.id val requestFull = wantFull || userOwned || pendingPlayback var previewFetchDone by remember(fullKey) { mutableStateOf(displayFile != null || fullFile != null) } + var cacheInvalidation by remember(mediaRepository, fullKey) { mutableStateOf(0) } LaunchedEffect(mediaRepository, thumbKey, displayKey, fullKey, cacheGenerationFlow) { val repo = mediaRepository ?: return@LaunchedEffect + fun reconcileCache() { - repo.cachedFile(thumbKey)?.let { thumbFile = it } - displayKey?.let { key -> - repo.cachedFile(key)?.let { - displayFile = it - previewFetchDone = true - } - } - repo.cachedFile(fullKey)?.let { - fullFile = it + val thumb = repo.cachedFile(thumbKey) + val display = displayKey?.let(repo::cachedFile) + val full = repo.cachedFile(fullKey) + val lost = + (thumbFile != null && thumb == null) || (displayFile != null && display == null) || (fullFile != null && full == null) + thumbFile = thumb + displayFile = display + fullFile = full + if (lost) { + previewFetchDone = display != null || full != null + cacheInvalidation += 1 + } else if (display != null || full != null) { previewFetchDone = true } } @@ -219,9 +225,7 @@ fun MessageMedia( } // Other downloads must not invalidate this message's media. val progress = remember(mediaRepository, fullKey) { - mediaRepository?.downloadProgress - ?.map { it[fullKey] ?: 0L } - ?.distinctUntilChanged() + mediaRepository?.downloadProgress?.map { it[fullKey] ?: 0L }?.distinctUntilChanged() ?: flowOf(0L) } @@ -234,6 +238,7 @@ fun MessageMedia( requestFull, kind, downloadAttempt, + cacheInvalidation, previewOnly, mediaVisible, preset, @@ -245,7 +250,10 @@ fun MessageMedia( previewFetchDone = true return@LaunchedEffect } - if (shouldCancelOnViewportDetach(mediaVisible, requestFull, false) && !repo.isUserDownload(fullKey)) { + if (shouldCancelOnViewportDetach(mediaVisible, requestFull, false) && !repo.isUserDownload( + fullKey + ) + ) { PerfLog.event("media_effect", "detach_cancel kind=$kind") repo.cancelRunning(thumbKey) displayKey?.let { repo.cancelRunning(it) } @@ -254,23 +262,34 @@ fun MessageMedia( } val displayPriority = MediaPriority.VISIBLE var fullCancelled = false + suspend fun fetch(kind: MediaFetchKind): File? = withContext(Dispatchers.IO) { var networkTries = 0 repeat(16) { val result = when (kind) { - MediaFetchKind.Thumb -> - repo.ensureLocalMessageThumb(message, priority = MediaPriority.THUMB) - MediaFetchKind.Display -> + MediaFetchKind.Thumb -> { + repo.ensureLocalMessageThumb( + message, priority = MediaPriority.THUMB + ) + } + + MediaFetchKind.Display -> { repo.ensureLocalMessageDisplay(message, priority = displayPriority) - MediaFetchKind.Full -> + } + + MediaFetchKind.Full -> { repo.ensureLocalMessageMedia( message, priority = mediaFullPriority(requestFull), retain = wantFull || userOwned, ) + } } when (result) { - is Outcome.Ok -> return@withContext result.value + is Outcome.Ok -> { + return@withContext result.value + } + is Outcome.Err -> { if (result.message == "cancelled") { if (kind == MediaFetchKind.Full) fullCancelled = true @@ -281,8 +300,8 @@ fun MessageMedia( } val deferred = result.message == "media deferred" val network = result.telegramError.kind == TelegramError.Kind.Network - val floodWait = result.telegramError.retryAfterSeconds - ?.takeIf { result.telegramError.kind == TelegramError.Kind.Flood } + val floodWait = + result.telegramError.retryAfterSeconds?.takeIf { result.telegramError.kind == TelegramError.Kind.Flood } if (deferred || floodWait != null || (network && networkTries < 2)) { if (network) networkTries += 1 delay( @@ -300,6 +319,7 @@ fun MessageMedia( } null } + val userRequested = requestFull || (playing && kind != "gif") val needFull = shouldAutoFetchFullMedia( kind, @@ -341,23 +361,26 @@ fun MessageMedia( fullFailed = false retainMediaFile(fullFile, fetch(MediaFetchKind.Full))?.let { fullFile = it } fullFailed = fullFile == null && !fullCancelled && (playing || requestFull) - failed = fullFile == null && - thumbFile == null && - !fullCancelled && - (playing || requestFull) + failed = + fullFile == null && thumbFile == null && !fullCancelled && (playing || requestFull) } } previewFetchDone = true } val preview = when (kind) { - "video", "video_note" -> + "video", "video_note" -> { listOfNotNull(thumbFile, displayFile, fullFile).firstOrNull { stillImageFile(it) } ?: thumbFile ?: displayFile ?: fullFile - "webpage" -> + } + + "webpage" -> { displayFile ?: thumbFile ?: fullFile - else -> + } + + else -> { fullFile ?: displayFile ?: thumbFile + } } val mediaVisualState = when { fullFile != null -> "loaded" @@ -380,11 +403,17 @@ fun MessageMedia( null } } - val stickerBytes by produceState(initialValue = null, fullFile, preview, kind) { + val stickerBytes by produceState( + initialValue = null, mediaRepository, fullKey, fullFile, preview, kind + ) { + value = null val file = fullFile ?: preview if (file != null && (kind == "sticker_animated" || gzipFile(file))) { - val bytes = withContext(Dispatchers.IO) { runCatching { file.readBytes() }.getOrNull() } - if (bytes != null) value = bytes + value = withContext(Dispatchers.IO) { + org.monogram.core.ui.FileLeases.hold(file) { + runCatching { file.readBytes() }.getOrNull() + } + } } } when { @@ -405,6 +434,7 @@ fun MessageMedia( .then(visibilityModifier), ) } + sticker && webmFile(stickerFile) && stickerFile != null -> { VpxStickerPlayer( file = stickerFile, @@ -420,6 +450,7 @@ fun MessageMedia( ), ) } + sticker && stickerBytes != null && gzipFile(fullFile ?: preview) -> { StickerPlayer( lottieBytes = stickerBytes!!, @@ -428,6 +459,7 @@ fun MessageMedia( active = mediaAnimationEnabled, ) } + kind == "gif" && playing && fullFile != null -> { Box(modifier = mediaModifier) { VideoPlayer( @@ -440,6 +472,7 @@ fun MessageMedia( ) } } + kind == "audio" || kind == "voice" -> { AudioBubble( message = message, @@ -455,7 +488,10 @@ fun MessageMedia( failed = fullFailed, downloadProgress = progress, fileSize = message.fileSize, - onRequest = { wantFull = true; playing = true }, + onRequest = { + wantFull = true + playing = true + }, onCancel = { wantFull = false playing = false @@ -469,12 +505,14 @@ fun MessageMedia( .then(visibilityModifier), ) } + !sticker && kind == "document" -> { DocumentBubble( onLongPress = onLongPress, file = fullFile, thumb = preview?.takeIf { stillImageFile(it) }, - name = message.fileName ?: message.text ?: stringResource(R.string.dialog_media_document), + name = message.fileName ?: message.text + ?: stringResource(R.string.dialog_media_document), size = message.fileSize, loading = requestFull && fullFile == null && !fullFailed, failed = fullFailed, @@ -507,6 +545,7 @@ fun MessageMedia( .then(visibilityModifier), ) } + kind == "video_note" -> { if (mediaRepository != null) { VideoNoteBubble( @@ -522,6 +561,7 @@ fun MessageMedia( ) } } + kind == "video" || kind == "gif" -> { val loadingFull = playing && fullFile == null && !fullFailed val stillThumb = thumbFile?.takeIf { stillImageFile(it) } @@ -538,7 +578,8 @@ fun MessageMedia( val autoplayVideo = inlineVideo && mediaVisible LaunchedEffect(message.id, message.supportsStreaming, autoplayVideo, mediaVisible) { if (kind != "video") return@LaunchedEffect - val name = message.fileName?.replace(Regex("[^A-Za-z0-9._-]"), "_")?.take(40).orEmpty() + val name = + message.fileName?.replace(Regex("[^A-Za-z0-9._-]"), "_")?.take(40).orEmpty() PerfLog.mark( "stream:autoplay:$name:streaming=${message.supportsStreaming}:start=$autoplayVideo", 0L, @@ -594,6 +635,7 @@ fun MessageMedia( ) } } + sticker || kind == "photo" -> { val description = stringResource( when { @@ -604,12 +646,13 @@ fun MessageMedia( val loadingFull = kind == "photo" && requestFull && fullFile == null && !fullFailed val stillThumb = thumbFile?.takeIf { stillImageFile(it) } val sharpStill = (fullFile ?: displayFile)?.takeIf { stillImageFile(it) } - val placeholder = !sticker && shouldBlurMediaPreview(thumbKey, fullKey, sharpStill != null) + val placeholder = + !sticker && shouldBlurMediaPreview(thumbKey, fullKey, sharpStill != null) val stillImage = sharpStill ?: stillThumb.takeIf { !placeholder } Box( modifier = mediaModifier .then( - if (sticker) stickerBox else Modifier + if (sticker) stickerBox else Modifier, ) .then( if (kind == "photo") { @@ -645,6 +688,7 @@ fun MessageMedia( ) } } + kind == "document" -> { if (fillBounds) { Box( @@ -660,6 +704,7 @@ fun MessageMedia( ) } } + else -> { Text( text = stringResource(R.string.dialog_media_document), diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaStill.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaStill.kt index 3a0b6f232..f2a9df8c6 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaStill.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaStill.kt @@ -8,8 +8,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -22,6 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import coil.request.CachePolicy import coil.request.ImageRequest +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import org.monogram.core.ui.components.MonogramPlaceholder @@ -67,19 +71,36 @@ internal fun ProgressiveStill( val context = LocalContext.current val sharp = image?.takeIf { stillImageFile(it) } val base = thumb?.takeIf { stillImageFile(it) } - val strippedJpeg = stripped?.takeIf { it.isNotEmpty() && sharp == null && base == null } + val strippedJpeg = stripped?.takeIf { it.isNotEmpty() } val hasFile = base != null || sharp != null || strippedJpeg != null - val blurBase = (base != null && base != sharp) || strippedJpeg != null - val baseRequest = remember(base, blurBase, strippedJpeg, context) { - when { - base != null -> localStillRequest(context, base, crossfade = 0, blur = blurBase) - strippedJpeg != null -> localStrippedRequest(context, strippedJpeg) - else -> null + val blurBase = base != null && base != sharp + var baseLoaded by remember(base, blurBase, base != sharp) { mutableStateOf(false) } + var sharpLoaded by remember(sharp, base) { mutableStateOf(false) } + var sharpVisible by remember(sharp, base) { mutableStateOf(false) } + LaunchedEffect(sharpLoaded, sharp, base) { + if (sharpLoaded) { + if (base != null && base != sharp) delay(220) + sharpVisible = true } } - val sharpRequest = remember(sharp, base, context) { - sharp?.let { localStillRequest(context, it, crossfade = if (base != null && base != sharp) 220 else 0) } - } + val previewRequest = + remember(strippedJpeg, context) { + strippedJpeg?.let { localStrippedRequest(context, it) } + } + val baseRequest = + remember(base, blurBase, context) { + base?.let { localStillRequest(context, it, crossfade = 0, blur = blurBase) } + } + val sharpRequest = + remember(sharp, base, context) { + sharp?.let { + localStillRequest( + context, + it, + crossfade = if (base != null && base != sharp) 220 else 0 + ) + } + } Box(modifier = modifier, contentAlignment = Alignment.Center) { if (!hasFile && !failed) { MonogramPlaceholder( @@ -87,12 +108,21 @@ internal fun ProgressiveStill( shape = RoundedCornerShape(12.dp), ) } + if (previewRequest != null && !baseLoaded && !sharpVisible) { + AsyncImage( + model = previewRequest, + contentDescription = contentDescription.takeIf { baseRequest == null && sharpRequest == null }, + contentScale = contentScale, + modifier = Modifier.fillMaxSize(), + ) + } if (baseRequest != null && base != sharp) { AsyncImage( model = baseRequest, - contentDescription = contentDescription, + contentDescription = contentDescription.takeIf { sharpRequest == null }, contentScale = contentScale, modifier = Modifier.fillMaxSize(), + onSuccess = { baseLoaded = true }, ) } if (sharpRequest != null) { @@ -101,6 +131,7 @@ internal fun ProgressiveStill( contentDescription = contentDescription, contentScale = contentScale, modifier = Modifier.fillMaxSize(), + onSuccess = { sharpLoaded = true }, ) } MediaProgressOverlay(visible = loading && sharp == null && hasFile) @@ -119,15 +150,16 @@ internal fun MediaTapModifier( onTap: () -> Unit, onLongPress: (() -> Unit)?, role: Role? = null, -): Modifier = if (onLongPress == null) { - Modifier.clickable(role = role, onClick = onTap) -} else { - Modifier.combinedClickable( - role = role, - onClick = onTap, - onLongClick = onLongPress, - ) -} +): Modifier = + if (onLongPress == null) { + Modifier.clickable(role = role, onClick = onTap) + } else { + Modifier.combinedClickable( + role = role, + onClick = onTap, + onLongClick = onLongPress, + ) + } private fun localStillRequest( context: android.content.Context, @@ -135,11 +167,13 @@ private fun localStillRequest( crossfade: Int, blur: Boolean = false, ): ImageRequest { - val builder = ImageRequest.Builder(context) - .data(file) - .memoryCacheKey(if (blur) "${file.absolutePath}:preview-blur" else file.absolutePath) - .diskCachePolicy(CachePolicy.DISABLED) - .crossfade(crossfade) + val builder = + ImageRequest + .Builder(context) + .data(file) + .memoryCacheKey(if (blur) "${file.absolutePath}:preview-blur" else file.absolutePath) + .diskCachePolicy(CachePolicy.DISABLED) + .crossfade(crossfade) if (blur) { builder.size(128).transformations(PreviewBlurTransformation()) } @@ -149,11 +183,13 @@ private fun localStillRequest( private fun localStrippedRequest( context: android.content.Context, jpeg: ByteArray, -): ImageRequest = ImageRequest.Builder(context) - .data(jpeg) - .memoryCacheKey("stripped:${jpeg.size}:${jpeg.contentHashCode()}") - .diskCachePolicy(CachePolicy.DISABLED) - .crossfade(0) - .size(128) - .transformations(PreviewBlurTransformation()) - .build() +): ImageRequest = + ImageRequest + .Builder(context) + .data(jpeg) + .memoryCacheKey("stripped:${jpeg.size}:${jpeg.contentHashCode()}") + .diskCachePolicy(CachePolicy.DISABLED) + .crossfade(0) + .size(128) + .transformations(PreviewBlurTransformation()) + .build() diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaViewer.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaViewer.kt index 49f35efb0..b05328ce7 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaViewer.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaViewer.kt @@ -1,52 +1,49 @@ package org.monogram.feature.dialog.ui +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import android.app.Activity import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalContext -import android.net.Uri +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.flow.flowOf -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.widget.Toast import org.monogram.core.common.Outcome import org.monogram.core.models.Message import org.monogram.core.models.MessageId import org.monogram.core.models.PeerId import org.monogram.core.models.canForwardFrom -import org.monogram.core.ui.media.MediaAlbumState +import org.monogram.core.ui.media.CaptionEntity +import org.monogram.core.ui.media.LocalPictureInPictureController +import org.monogram.core.ui.media.MediaHdr import org.monogram.core.ui.media.MediaPlaybackHolder import org.monogram.core.ui.media.MediaSource import org.monogram.core.ui.media.MediaViewerActions import org.monogram.core.ui.media.MediaViewerHost import org.monogram.core.ui.media.MediaViewerItem -import org.monogram.core.ui.media.LocalPictureInPictureController import org.monogram.core.ui.media.MediaViewerKind -import org.monogram.core.ui.media.CaptionEntity -import org.monogram.core.ui.media.MediaHdr import org.monogram.core.ui.media.rememberAlbumState import org.monogram.feature.dialog.R import org.monogram.network.http.MediaPriority @@ -69,9 +66,10 @@ internal val LocalChatTitleHolder = staticCompositionLocalOf { /** The album the tapped cell belongs to, published by the mosaic. */ internal val LocalAlbumMessages = staticCompositionLocalOf> { emptyList() } -internal val LocalOpenMessageMedia = staticCompositionLocalOf<(Message, List) -> Unit> { - { _, _ -> error("Message media must be hosted by MessageMediaViewerScope") } -} +internal val LocalOpenMessageMedia = + staticCompositionLocalOf<(Message, List) -> Unit> { + { _, _ -> error("Message media must be hosted by MessageMediaViewerScope") } + } internal class ReadReceiptHolder { var label: ((Message) -> String?)? by mutableStateOf(null) @@ -90,36 +88,54 @@ private fun MediaRepository.streamUri(message: Message): Uri = Uri.parse("telegram://media/${message.id.chatId.value}/${message.id.id}") /** Only a small descriptor is saved, never file bytes or the chat's history. */ -internal val MessageSaver = listSaver( - save = { message -> - if (message == null) emptyList() else encodeViewerMessages(listOf(message)) - }, - restore = { values -> if (values.isEmpty()) null else decodeMessages(values).firstOrNull() }, -) +internal val MessageSaver = + listSaver( + save = { message -> + if (message == null) emptyList() else encodeViewerMessages(listOf(message)) + }, + restore = { values -> if (values.isEmpty()) null else decodeMessages(values).firstOrNull() }, + ) /** Album identity survives rotation without keeping the whole history in a Bundle. */ -internal val AlbumSaver = listSaver, Any>( - save = { messages -> encodeViewerMessages(messages) }, - restore = { values -> decodeMessages(values) }, -) +internal val AlbumSaver = + listSaver, Any>( + save = { messages -> encodeViewerMessages(messages) }, + restore = { values -> decodeMessages(values) }, + ) private const val VIEWER_MESSAGE_FORMAT = "message-media-viewer" private const val VIEWER_MESSAGE_VERSION = 1 private fun encodeViewerMessages(messages: List): List = - listOf(VIEWER_MESSAGE_FORMAT, VIEWER_MESSAGE_VERSION) + messages.map { message -> - arrayListOf( - message.id.chatId.value, message.id.id, message.mediaKind, - message.mediaCacheKey, message.thumbCacheKey, message.text, message.fileSize, - message.mediaDuration, message.mediaWidth, message.mediaHeight, - message.date, message.noforwards, message.groupedId, message.senderId?.value, - message.senderName, message.outgoing, message.fileName, message.supportsStreaming, - message.reactionsJson, - ArrayList(message.entities.map { entity -> - arrayListOf(entity.kind, entity.offset, entity.length, entity.url) - }), - ) - } + listOf(VIEWER_MESSAGE_FORMAT, VIEWER_MESSAGE_VERSION) + + messages.map { message -> + arrayListOf( + message.id.chatId.value, + message.id.id, + message.mediaKind, + message.mediaCacheKey, + message.thumbCacheKey, + message.text, + message.fileSize, + message.mediaDuration, + message.mediaWidth, + message.mediaHeight, + message.date, + message.noforwards, + message.groupedId, + message.senderId?.value, + message.senderName, + message.outgoing, + message.fileName, + message.supportsStreaming, + message.reactionsJson, + ArrayList( + message.entities.map { entity -> + arrayListOf(entity.kind, entity.offset, entity.length, entity.url) + }, + ), + ) + } private fun decodeMessages(values: List): List { if (values.firstOrNull() == VIEWER_MESSAGE_FORMAT) { @@ -145,15 +161,16 @@ private fun decodeMessages(values: List): List { fileName = fields[16] as String?, supportsStreaming = fields[17] as Boolean, reactionsJson = fields[18] as String?, - entities = (fields[19] as List<*>).map { savedEntity -> - val entity = savedEntity as List<*> - org.monogram.core.models.TextEntity( - kind = entity[0] as String, - offset = entity[1] as Int, - length = entity[2] as Int, - url = entity[3] as String?, - ) - }, + entities = + (fields[19] as List<*>).map { savedEntity -> + val entity = savedEntity as List<*> + org.monogram.core.models.TextEntity( + kind = entity[0] as String, + offset = entity[1] as Int, + length = entity[2] as Int, + url = entity[3] as String?, + ) + }, ) } } @@ -194,7 +211,12 @@ internal fun MessageMediaViewerScope( chatCanForward: Boolean = true, onForward: (List) -> Unit = {}, onDelete: (List, Boolean) -> Unit = { _, _ -> }, - deleteOffer: (List) -> DeleteOffer = { DeleteOffer(forMe = true, forEveryone = false) }, + deleteOffer: (List) -> DeleteOffer = { + DeleteOffer( + forMe = true, + forEveryone = false + ) + }, onShowInChat: (Message) -> Unit = {}, onReply: ((Message) -> Unit)? = null, onEnsureReceipts: (Message) -> Unit = {}, @@ -229,9 +251,10 @@ internal fun MessageMediaViewerScope( val knownSiblings = groupedMessage?.let { knownAlbumMessages?.invoke(it) }.orEmpty() LaunchedEffect(groupedMessage?.id, knownSiblings) { val message = groupedMessage ?: return@LaunchedEffect - val siblings = knownSiblings.filter { - it.id.chatId == message.id.chatId && it.groupedId == message.groupedId - } + val siblings = + knownSiblings.filter { + it.id.chatId == message.id.chatId && it.groupedId == message.groupedId + } if (siblings.isNotEmpty()) { album = (album + siblings).distinctBy { it.id }.sortedBy { it.id.id } } @@ -291,30 +314,39 @@ private fun MessageMediaViewer( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val resolved = remember { mutableStateMapOf() } + val contentIdentity = album.map { it.mediaCacheKey to it.thumbCacheKey } + val resolved = remember(repository, contentIdentity) { mutableStateMapOf() } var pendingDelete by remember { mutableStateOf?>(null) } var pendingSaveAsPath by rememberSaveable { mutableStateOf(null) } - val saveAsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val source = pendingSaveAsPath?.let(::File) - pendingSaveAsPath = null - val destination = result.data?.data - if (result.resultCode == Activity.RESULT_OK && source != null && destination != null) { - scope.launch { - val saved = MediaShareActions.saveToDocument(context, source, destination) - Toast.makeText( - context, - if (saved) R.string.media_action_saved else R.string.media_action_save_failed, - Toast.LENGTH_SHORT, - ).show() + val saveAsLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val source = pendingSaveAsPath?.let(::File) + pendingSaveAsPath = null + val destination = result.data?.data + if (result.resultCode == Activity.RESULT_OK && source != null && destination != null) { + scope.launch { + val saved = MediaShareActions.saveToDocument(context, source, destination) + Toast + .makeText( + context, + if (saved) R.string.media_action_saved else R.string.media_action_save_failed, + Toast.LENGTH_SHORT, + ).show() + } } } - } - val inFlight = remember { mutableMapOf>() } + val inFlight = + remember(repository, contentIdentity) { mutableMapOf>() } val session = remember(context) { MediaPlaybackHolder.session(context) } - fun resolve(message: Message, force: Boolean = false, priority: Int = MediaPriority.USER) { + fun resolve( + message: Message, + force: Boolean = false, + priority: Int = MediaPriority.USER, + ) { val key = messageKey(message) - val video = message.mediaKind == "video" || message.mediaKind == "gif" || message.mediaKind == "video_note" + val video = + message.mediaKind == "video" || message.mediaKind == "gif" || message.mediaKind == "video_note" val cached = message.mediaCacheKey?.let { repository?.cachedFile(it) } if (cached != null) { resolved[key] = Resolved(cached, loading = false, failed = false) @@ -326,91 +358,152 @@ private fun MessageMediaViewer( existing?.second?.cancel() resolved[key] = Resolved(null, loading = !video, failed = false) lateinit var request: Job - request = scope.launch(start = CoroutineStart.LAZY) { - try { - val result = withContext(Dispatchers.IO) { - repository.ensureLocalMessageMedia(message, priority) - } - when (result) { - is Outcome.Ok -> resolved[key] = Resolved(result.value, loading = false, failed = false) - is Outcome.Err -> resolved[key] = Resolved(null, loading = false, failed = true) + request = + scope.launch(start = CoroutineStart.LAZY) { + try { + val result = + withContext(Dispatchers.IO) { + repository.ensureLocalMessageMedia(message, priority) + } + when (result) { + is Outcome.Ok -> resolved[key] = + Resolved(result.value, loading = false, failed = false) + + is Outcome.Err -> resolved[key] = + Resolved(null, loading = false, failed = true) + } + } finally { + if (inFlight[key]?.second === request) inFlight.remove(key) } - } finally { - if (inFlight[key]?.second === request) inFlight.remove(key) } - } - inFlight[key] = priority to request + inFlight[key] = Triple(priority, request, message.mediaCacheKey) request.start() } val progressFlow = remember(repository) { repository?.downloadProgress ?: flowOf(emptyMap()) } val downloadProgress by progressFlow.collectAsStateWithLifecycle(initialValue = emptyMap()) - val items = album.mapIndexed { albumIndex, message -> - val key = messageKey(message) - val video = message.mediaKind == "video" || message.mediaKind == "gif" || message.mediaKind == "video_note" - val kind = viewerKind(message.mediaKind) - val state = resolved[key] - val localFile = state?.file - val preview = remember(key, repository) { - message.mediaCacheKey?.let { repository?.cachedFile(photoDisplayCacheKey(it)) } - ?: message.thumbCacheKey?.let { repository?.cachedFile(it) } - } - MediaViewerItem( - id = key, - kind = kind, - source = when { - localFile != null -> MediaSource.Local( - localFile, - message.mediaCacheKey?.let { repository?.createCachedDataSourceFactory(it) }) - kind in listOf(MediaViewerKind.VIDEO, MediaViewerKind.VIDEO_NOTE, MediaViewerKind.AUDIO, MediaViewerKind.VOICE) && repository != null -> MediaSource.Stream( - uri = repository.streamUri(message), - factory = repository.createMessageDataSourceFactory(message), - ) - else -> null - }, - // Photos keep their cached thumbnail as the placeholder and the filmstrip - // cell, so an album strip is never a row of empty squares. - preview = preview, - caption = message.text, - captionEntities = message.entities.mapNotNull { entity -> - when (entity.kind.lowercase(Locale.ROOT)) { - "text_url", "url" -> CaptionEntity(entity.offset, entity.length, CaptionEntity.Type.URL, entity.url) - "mention" -> CaptionEntity(entity.offset, entity.length, CaptionEntity.Type.MENTION) - "mention_name", "text_mention" -> CaptionEntity( - entity.offset, entity.length, CaptionEntity.Type.MENTION, - entity.url?.toLongOrNull()?.takeIf { it > 0 }?.let { "tg://user?id=$it" }, - ) - "bold" -> CaptionEntity(entity.offset, entity.length, CaptionEntity.Type.BOLD) - else -> null + val items = + album.mapIndexed { albumIndex, message -> + val key = messageKey(message) + val video = + message.mediaKind == "video" || message.mediaKind == "gif" || message.mediaKind == "video_note" + val kind = viewerKind(message.mediaKind) + val state = resolved[key] + val localFile = state?.file?.takeIf { it.isFile } + val preview = + remember(key, repository, message.mediaCacheKey, message.thumbCacheKey) { + message.mediaCacheKey?.let { repository?.cachedFile(photoDisplayCacheKey(it)) } + ?: message.thumbCacheKey?.let { repository?.cachedFile(it) } } - }, - durationSeconds = message.mediaDuration, - albumIndex = albumIndex, - albumCount = album.size, - groupedId = message.groupedId, - spoiler = message.entities.any { it.kind.equals("spoiler", ignoreCase = true) }, - width = message.mediaWidth, - height = message.mediaHeight, - progress = message.fileSize?.takeIf { it > 0L }?.let { size -> - downloadProgress[message.mediaCacheKey]?.let { (it.toDouble() / size).toFloat().coerceIn(0f, 1f) } - }, - aspectRatio = mediaAspectRatio(message.mediaWidth, message.mediaHeight), - senderName = chatTitle ?: message.senderName, - dateLabel = relativeDateLabel(message.date), - dateMillis = message.date * 1000L, - protectedContent = !chatCanForward || message.noforwards, - loading = kind != MediaViewerKind.UNKNOWN && (state?.loading ?: !video), - failed = kind == MediaViewerKind.UNKNOWN || state?.failed == true, - sourceChatId = message.id.chatId.value, - sourceMessageId = message.id.id, - fileSize = message.fileSize, - fileName = message.fileName, - forceLoop = message.mediaKind == "gif", - ) - } + MediaViewerItem( + id = key, + contentRevision = "${repository?.cacheRoot?.absolutePath}:${message.mediaCacheKey}", + kind = kind, + source = + when { + localFile != null -> { + MediaSource.Local( + localFile, + message.mediaCacheKey?.let { + repository?.createCachedDataSourceFactory( + it + ) + }, + ) + } + + kind in listOf( + MediaViewerKind.VIDEO, + MediaViewerKind.VIDEO_NOTE, + MediaViewerKind.AUDIO, + MediaViewerKind.VOICE + ) && + repository != null -> { + MediaSource.Stream( + uri = repository.streamUri(message), + factory = repository.createMessageDataSourceFactory(message), + ) + } + + else -> { + null + } + }, + // Photos keep their cached thumbnail as the placeholder and the filmstrip + // cell, so an album strip is never a row of empty squares. + preview = preview, + caption = message.text, + captionEntities = + message.entities.mapNotNull { entity -> + when (entity.kind.lowercase(Locale.ROOT)) { + "text_url", "url" -> { + CaptionEntity( + entity.offset, + entity.length, + CaptionEntity.Type.URL, + entity.url + ) + } + + "mention" -> { + CaptionEntity( + entity.offset, + entity.length, + CaptionEntity.Type.MENTION + ) + } + + "mention_name", "text_mention" -> { + CaptionEntity( + entity.offset, + entity.length, + CaptionEntity.Type.MENTION, + entity.url + ?.toLongOrNull() + ?.takeIf { it > 0 } + ?.let { "tg://user?id=$it" }, + ) + } + + "bold" -> { + CaptionEntity(entity.offset, entity.length, CaptionEntity.Type.BOLD) + } + + else -> { + null + } + } + }, + durationSeconds = message.mediaDuration, + albumIndex = albumIndex, + albumCount = album.size, + groupedId = message.groupedId, + spoiler = message.entities.any { it.kind.equals("spoiler", ignoreCase = true) }, + width = message.mediaWidth, + height = message.mediaHeight, + progress = + message.fileSize?.takeIf { it > 0L }?.let { size -> + downloadProgress[message.mediaCacheKey]?.let { + (it.toDouble() / size).toFloat().coerceIn(0f, 1f) + } + }, + aspectRatio = mediaAspectRatio(message.mediaWidth, message.mediaHeight), + senderName = chatTitle ?: message.senderName, + dateLabel = relativeDateLabel(message.date), + dateMillis = message.date * 1000L, + protectedContent = !chatCanForward || message.noforwards, + loading = kind != MediaViewerKind.UNKNOWN && (state?.loading ?: !video), + failed = kind == MediaViewerKind.UNKNOWN || state?.failed == true, + sourceChatId = message.id.chatId.value, + sourceMessageId = message.id.id, + fileSize = message.fileSize, + fileName = message.fileName, + forceLoop = message.mediaKind == "gif", + ) + } val albumState = rememberAlbumState(items, startIndex) val activeId = albumState.current?.id - LaunchedEffect(activeId, album.map { it.id }) { + LaunchedEffect(activeId, contentIdentity, repository) { val currentIndex = album.indexOfFirst { messageKey(it) == activeId } if (currentIndex < 0) return@LaunchedEffect val window = (currentIndex - 1..currentIndex + 1).mapNotNull(album::getOrNull) @@ -419,9 +512,7 @@ private fun MessageMediaViewer( if (request.first == MediaPriority.IDLE && id !in windowIds) { inFlight.remove(id) request.second.cancel() - album.firstOrNull { messageKey(it) == id }?.mediaCacheKey?.let { - repository?.cancelRunning(it) - } + request.third?.let { repository?.cancelRunning(it) } } } resolve(album[currentIndex], priority = MediaPriority.USER) @@ -429,23 +520,25 @@ private fun MessageMediaViewer( resolve(it, priority = MediaPriority.IDLE) } } - val latestAlbum by rememberUpdatedState(album) - DisposableEffect(repository) { + DisposableEffect(repository, inFlight) { onDispose { - inFlight.forEach { (id, request) -> + inFlight.values.toList().forEach { request -> + request.second.cancel() if (request.first == MediaPriority.IDLE) { - latestAlbum.firstOrNull { messageKey(it) == id }?.mediaCacheKey?.let { - repository?.cancelRunning(it) - } + request.third?.let { repository?.cancelRunning(it) } } } } } val pictureInPicture = LocalPictureInPictureController.current - fun withLocalMedia(item: MediaViewerItem, action: (File) -> Unit) { + fun withLocalMedia( + item: MediaViewerItem, + action: (File) -> Unit, + ) { val message = album.firstOrNull { messageKey(it) == item.id } ?: return - val cached = resolved[item.id]?.file ?: message.mediaCacheKey?.let { repository?.cachedFile(it) } + val cached = + resolved[item.id]?.file ?: message.mediaCacheKey?.let { repository?.cachedFile(it) } if (cached != null && cached.exists()) { action(cached) return @@ -453,9 +546,10 @@ private fun MessageMediaViewer( if (repository == null) return Toast.makeText(context, R.string.media_action_preparing, Toast.LENGTH_SHORT).show() scope.launch { - val result = withContext(Dispatchers.IO) { - repository.ensureLocalMessageMedia(message, MediaPriority.USER) - } + val result = + withContext(Dispatchers.IO) { + repository.ensureLocalMessageMedia(message, MediaPriority.USER) + } val file = (result as? Outcome.Ok)?.value if (file == null || !file.exists()) { Toast.makeText(context, R.string.dialog_media_failed, Toast.LENGTH_SHORT).show() @@ -466,7 +560,10 @@ private fun MessageMediaViewer( } } - fun mediaName(item: MediaViewerItem, file: File): String { + fun mediaName( + item: MediaViewerItem, + file: File, + ): String { val named = item.fileName?.takeIf { it.isNotBlank() } val base = named ?: "monogram_${item.id.replace(':', '_')}" val mime = MediaShareActions.mimeFor(base, item.isVideo) @@ -477,151 +574,221 @@ private fun MessageMediaViewer( } } - val actions = MediaViewerActions( - onCaptionUrl = { url -> onDismiss(); onCaptionUrl(url) }, - onCaptionMention = { mention -> onDismiss(); onCaptionMention(mention) }, - onReply = messageMediaReplyAction( - album = album, - onDismiss = onDismiss, - onReply = onReply.takeIf { - album.firstOrNull { messageKey(it) == activeId } - ?.let { !it.pending && it.id.id > 0 } == true + val actions = + MediaViewerActions( + onCaptionUrl = { url -> + onDismiss() + onCaptionUrl(url) }, - ), - onShare = { item -> - val targets = albumState.selectedItems().filterNot { it.protectedContent } - scope.launch { - val files = withContext(Dispatchers.IO) { - targets.mapNotNull { target -> - val message = album.firstOrNull { messageKey(it) == target.id } ?: return@mapNotNull null - val cached = (target.source as? MediaSource.Local)?.file - cached ?: (repository?.ensureLocalMessageMedia(message, MediaPriority.USER) as? Outcome.Ok)?.value + onCaptionMention = { mention -> + onDismiss() + onCaptionMention(mention) + }, + onReply = + messageMediaReplyAction( + album = album, + onDismiss = onDismiss, + onReply = + onReply.takeIf { + album + .firstOrNull { messageKey(it) == activeId } + ?.let { !it.pending && it.id.id > 0 } == true + }, + ), + onShare = { item -> + val targets = albumState.selectedItems().filterNot { it.protectedContent } + scope.launch { + val files = + withContext(Dispatchers.IO) { + targets.mapNotNull { target -> + val message = album.firstOrNull { messageKey(it) == target.id } + ?: return@mapNotNull null + val cached = (target.source as? MediaSource.Local)?.file + cached ?: (repository?.ensureLocalMessageMedia( + message, + MediaPriority.USER + ) as? Outcome.Ok)?.value + } + } + if (files.size != targets.size || files.isEmpty()) { + Toast.makeText(context, R.string.dialog_media_failed, Toast.LENGTH_SHORT) + .show() + } else { + val mime = + if (targets.all { it.kind == MediaViewerKind.PHOTO }) { + "image/*" + } else if (targets.all { it.isVideo }) { + "video/*" + } else { + "*/*" + } + val shared = + if (files.size == 1) { + MediaShareActions.share(context, files.first(), mime) + } else { + MediaShareActions.shareMultiple(context, files, mime) + } + if (!shared) Toast.makeText( + context, + R.string.media_action_no_app, + Toast.LENGTH_SHORT + ).show() } } - if (files.size != targets.size || files.isEmpty()) { - Toast.makeText(context, R.string.dialog_media_failed, Toast.LENGTH_SHORT).show() - } else { - val mime = if (targets.all { it.kind == MediaViewerKind.PHOTO }) "image/*" - else if (targets.all { it.isVideo }) "video/*" else "*/*" - val shared = if (files.size == 1) MediaShareActions.share(context, files.first(), mime) - else MediaShareActions.shareMultiple(context, files, mime) - if (!shared) Toast.makeText(context, R.string.media_action_no_app, Toast.LENGTH_SHORT).show() + }, + onSaveAs = { item -> + if (!item.protectedContent && pendingSaveAsPath == null) { + withLocalMedia(item) { file -> + pendingSaveAsPath = file.absolutePath + val name = mediaName(item, file) + try { + saveAsLauncher.launch( + MediaShareActions.createDocumentIntent( + MediaShareActions.mimeFor(name, item.isVideo), + name, + ), + ) + } catch (_: android.content.ActivityNotFoundException) { + pendingSaveAsPath = null + Toast.makeText( + context, + R.string.media_action_no_app, + Toast.LENGTH_SHORT + ).show() + } + } } - } - }, - onSaveAs = { item -> - if (!item.protectedContent && pendingSaveAsPath == null) { + }, + onSave = { item -> + albumState.selectedItems().filterNot { it.protectedContent }.forEach { target -> + withLocalMedia(target) { file -> + scope.launch { + val mime = + MediaShareActions.mimeFor(mediaName(target, file), target.isVideo) + val saved = MediaShareActions.saveToGallery( + context, + file, + mime, + mediaName(target, file) + ) + Toast + .makeText( + context, + if (saved != null) R.string.media_action_saved else R.string.media_action_save_failed, + Toast.LENGTH_SHORT, + ).show() + } + } + } + }, + onCopyMedia = { item -> withLocalMedia(item) { file -> - pendingSaveAsPath = file.absolutePath - val name = mediaName(item, file) - try { - saveAsLauncher.launch(MediaShareActions.createDocumentIntent( - MediaShareActions.mimeFor(name, item.isVideo), name, - )) - } catch (_: android.content.ActivityNotFoundException) { - pendingSaveAsPath = null - Toast.makeText(context, R.string.media_action_no_app, Toast.LENGTH_SHORT).show() + val mime = MediaShareActions.mimeFor(mediaName(item, file), item.isVideo) + scope.launch { + val copied = withContext(Dispatchers.IO) { + MediaShareActions.copyToClipboard( + context, + file, + mime + ) + } + Toast + .makeText( + context, + when { + !copied -> R.string.media_action_save_failed + item.hdr == MediaHdr.GainMap -> org.monogram.core.ui.R.string.media_copy_hdr_warning + item.isVideo -> R.string.media_action_copied_video + else -> R.string.media_action_copied_image + }, + Toast.LENGTH_SHORT, + ).show() } } - } - }, - onSave = { item -> - albumState.selectedItems().filterNot { it.protectedContent }.forEach { target -> - withLocalMedia(target) { file -> - scope.launch { - val mime = MediaShareActions.mimeFor(mediaName(target, file), target.isVideo) - val saved = MediaShareActions.saveToGallery(context, file, mime, mediaName(target, file)) - Toast.makeText( - context, - if (saved != null) R.string.media_action_saved else R.string.media_action_save_failed, - Toast.LENGTH_SHORT, - ).show() + }, + onCopyCaption = { item -> + val caption = item.caption + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + if (!caption.isNullOrBlank()) { + clipboard.setPrimaryClip(ClipData.newPlainText("caption", caption)) } - } - } - }, - onCopyMedia = { item -> - withLocalMedia(item) { file -> - val mime = MediaShareActions.mimeFor(mediaName(item, file), item.isVideo) - scope.launch { - val copied = withContext(Dispatchers.IO) { MediaShareActions.copyToClipboard(context, file, mime) } - Toast.makeText( - context, - when { - !copied -> R.string.media_action_save_failed - item.hdr == MediaHdr.GainMap -> org.monogram.core.ui.R.string.media_copy_hdr_warning - item.isVideo -> R.string.media_action_copied_video - else -> R.string.media_action_copied_image - }, - Toast.LENGTH_SHORT, - ).show() + }, + onForward = { item, wholeAlbum -> + val ids = albumState.selectedItems().mapTo(mutableSetOf()) { it.id } + val messages = + ( + if (wholeAlbum) { + album + } else { + album.filter { messageKey(it) in ids } + } + ).filter { it.canForwardFrom(chatCanForward) } + if (messages.isNotEmpty()) { + onForward(messages) + onDismiss() } - } - }, - onCopyCaption = { item -> - val caption = item.caption - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - if (!caption.isNullOrBlank()) { - clipboard.setPrimaryClip(ClipData.newPlainText("caption", caption)) - } - }, - onForward = { item, wholeAlbum -> - val ids = albumState.selectedItems().mapTo(mutableSetOf()) { it.id } - val messages = (if (wholeAlbum) album else { - album.filter { messageKey(it) in ids } - }).filter { it.canForwardFrom(chatCanForward) } - if (messages.isNotEmpty()) { - onForward(messages) + }, + onDelete = { item, wholeAlbum -> + val ids = albumState.selectedItems().mapTo(mutableSetOf()) { it.id } + val targets = if (wholeAlbum) album else album.filter { messageKey(it) in ids } + if (deleteOffer(targets).visible) pendingDelete = targets + }, + onShowInChat = { item -> + album.firstOrNull { messageKey(it) == item.id }?.let(onShowInChat) onDismiss() - } - }, - onDelete = { item, wholeAlbum -> - val ids = albumState.selectedItems().mapTo(mutableSetOf()) { it.id } - val targets = if (wholeAlbum) album else album.filter { messageKey(it) in ids } - if (deleteOffer(targets).visible) pendingDelete = targets - }, - onShowInChat = { item -> - album.firstOrNull { messageKey(it) == item.id }?.let(onShowInChat) - onDismiss() - }, - onOpenExternally = { item -> - withLocalMedia(item) { file -> - val mime = MediaShareActions.mimeFor(mediaName(item, file), item.isVideo) - if (!MediaShareActions.openExternally(context, file, mime)) { - Toast.makeText(context, R.string.media_action_no_app, Toast.LENGTH_SHORT).show() + }, + onOpenExternally = { item -> + withLocalMedia(item) { file -> + val mime = MediaShareActions.mimeFor(mediaName(item, file), item.isVideo) + if (!MediaShareActions.openExternally(context, file, mime)) { + Toast.makeText(context, R.string.media_action_no_app, Toast.LENGTH_SHORT) + .show() + } } - } - }, - onRetry = { item -> album.firstOrNull { messageKey(it) == item.id }?.let { resolve(it, force = true) } }, - seenByLabel = seenByLabel?.let { label -> - { item -> album.firstOrNull { messageKey(it) == item.id }?.let(label) } - }, - onOpenSeenBy = onOpenSeenBy?.let { open -> - { item -> album.firstOrNull { messageKey(it) == item.id }?.let(open) } - }, - canRetry = true, - canDelete = album.any { deleteOffer(listOf(it)).visible }, - canForward = chatCanForward, - canPictureInPicture = pictureInPicture?.supported == true, - onEnterPictureInPicture = { pictureInPicture?.enter() }, - onListenInBackground = { - session.listenInBackground() - onDismiss() - }, - ) + }, + onRetry = { item -> + album.firstOrNull { messageKey(it) == item.id }?.let { resolve(it, force = true) } + }, + seenByLabel = + seenByLabel?.let { label -> + { item -> album.firstOrNull { messageKey(it) == item.id }?.let(label) } + }, + onOpenSeenBy = + onOpenSeenBy?.let { open -> + { item -> album.firstOrNull { messageKey(it) == item.id }?.let(open) } + }, + canRetry = true, + canDelete = album.any { deleteOffer(listOf(it)).visible }, + canForward = chatCanForward, + canPictureInPicture = pictureInPicture?.supported == true, + onEnterPictureInPicture = { pictureInPicture?.enter() }, + onListenInBackground = { + session.listenInBackground() + onDismiss() + }, + ) MediaViewerHost( album = albumState, onDismiss = onDismiss, actions = actions, - chatKey = album.firstOrNull()?.id?.chatId?.value?.toString().orEmpty(), + chatKey = + album + .firstOrNull() + ?.id + ?.chatId + ?.value + ?.toString() + .orEmpty(), headerTitle = chatTitle, session = session, onRequestItem = { item -> val target = album.firstOrNull { messageKey(it) == item.id } ?: return@MediaViewerHost val targetIndex = album.indexOfFirst { it.id == target.id } if (kotlin.math.abs(targetIndex - albumState.index) <= 1) { - val priority = if (targetIndex == albumState.index) MediaPriority.USER else MediaPriority.IDLE + val priority = + if (targetIndex == albumState.index) MediaPriority.USER else MediaPriority.IDLE resolve(target, priority = priority) } }, @@ -650,28 +817,33 @@ internal fun messageMediaReplyAction( album: List, onDismiss: () -> Unit, onReply: ((Message) -> Unit)?, -): ((MediaViewerItem) -> Unit)? = onReply?.let { reply -> - { item -> - album.firstOrNull { messageKey(it) == item.id && !it.pending && it.id.id > 0 }?.let { message -> - onDismiss() - reply(message) +): ((MediaViewerItem) -> Unit)? = + onReply?.let { reply -> + { item -> + album.firstOrNull { messageKey(it) == item.id && !it.pending && it.id.id > 0 } + ?.let { message -> + onDismiss() + reply(message) + } } } -} - -internal fun viewerKind(mediaKind: String?): MediaViewerKind = when (mediaKind?.lowercase(Locale.ROOT)) { - "photo" -> MediaViewerKind.PHOTO - "video" -> MediaViewerKind.VIDEO - "gif" -> MediaViewerKind.VIDEO - "video_note" -> MediaViewerKind.VIDEO_NOTE - "voice" -> MediaViewerKind.VOICE - "audio" -> MediaViewerKind.AUDIO - else -> MediaViewerKind.UNKNOWN -} +internal fun viewerKind(mediaKind: String?): MediaViewerKind = + when (mediaKind?.lowercase(Locale.ROOT)) { + "photo" -> MediaViewerKind.PHOTO + "video" -> MediaViewerKind.VIDEO + "gif" -> MediaViewerKind.VIDEO + "video_note" -> MediaViewerKind.VIDEO_NOTE + "voice" -> MediaViewerKind.VOICE + "audio" -> MediaViewerKind.AUDIO + else -> MediaViewerKind.UNKNOWN + } /** "14:32" today, "yesterday", "12 Sep" this year, otherwise a dated label. */ -internal fun relativeDateLabel(epochSeconds: Long, nowSeconds: Long = System.currentTimeMillis() / 1000): String { +internal fun relativeDateLabel( + epochSeconds: Long, + nowSeconds: Long = System.currentTimeMillis() / 1000, +): String { if (epochSeconds <= 0L) return "" val zone = ZoneId.systemDefault() val moment = Instant.ofEpochSecond(epochSeconds).atZone(zone) @@ -680,8 +852,20 @@ internal fun relativeDateLabel(epochSeconds: Long, nowSeconds: Long = System.cur return when { day == today -> moment.format(DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault())) day == today.minusDays(1) -> "yesterday" - day.isAfter(today.minusDays(7)) -> moment.format(DateTimeFormatter.ofPattern("EEE", Locale.getDefault())) - day.year == today.year -> moment.format(DateTimeFormatter.ofPattern("d MMM", Locale.getDefault())) + day.isAfter(today.minusDays(7)) -> moment.format( + DateTimeFormatter.ofPattern( + "EEE", + Locale.getDefault() + ) + ) + + day.year == today.year -> moment.format( + DateTimeFormatter.ofPattern( + "d MMM", + Locale.getDefault() + ) + ) + else -> moment.format(DateTimeFormatter.ofPattern("d MMM yyyy", Locale.getDefault())) } } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageSeenRow.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageSeenRow.kt index d12ba3395..413dff1a6 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageSeenRow.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageSeenRow.kt @@ -128,7 +128,9 @@ fun MessageSeenByRow( } } - MessageViewers.Expired, MessageViewers.TooBig, MessageViewers.Unavailable -> Unit + MessageViewers.Expired, MessageViewers.TooBig, MessageViewers.Unavailable -> { + Unit + } } } } @@ -230,8 +232,7 @@ internal fun PeerListSheet( items( visible, key = { - "${it.peerId.value}:${it.date}:${it.emoticon}:${it.documentId}:" + - it.pollOptionHex.joinToString() + "${it.peerId.value}:${it.date}:${it.emoticon}:${it.documentId}:" + it.pollOptionHex.joinToString() }, contentType = { "seen-peer" }, ) { viewer -> @@ -248,23 +249,33 @@ internal fun PeerListSheet( } } } - if (page?.failed == true) Text( - stringResource(R.string.dialog_peer_list_load_failed), - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - ) + if (page?.failed == true) { + Text( + stringResource(R.string.dialog_peer_list_load_failed), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + } if (onLoadMore != null && page != null && (page.loaded || page.failed) && (page.nextOffset != null || page.failed)) { MonogramBusyButton( - onClick = onLoadMore, busy = page.loading, + onClick = onLoadMore, + busy = page.loading, modifier = Modifier .fillMaxWidth() .padding(horizontal = 20.dp, vertical = 8.dp), - ) { Text(stringResource(if (page.failed) org.monogram.core.ui.R.string.status_retry else R.string.dialog_peer_list_load_more)) } + ) { + Text( + stringResource(if (page.failed) org.monogram.core.ui.R.string.status_retry else R.string.dialog_peer_list_load_more), + ) + } } } } -private fun chipLabel(chip: PeerListFilterChip, allLabel: String): String { +private fun chipLabel( + chip: PeerListFilterChip, + allLabel: String, +): String { val name = when { chip.key == PEER_LIST_FILTER_ALL -> allLabel !chip.label.isNullOrBlank() -> chip.label.orEmpty() @@ -314,8 +325,16 @@ private fun PeerChoiceRow( title = viewer.title.orEmpty(), size = 40.dp, imageFile = viewerAvatar(viewer), - previewBytes = rememberStrippedPreview(viewer.peerId) { - mediaRepository?.peerInlineThumbJpeg(viewer.peerId) + previewBytes = rememberStrippedPreview( + Triple( + mediaRepository, viewer.peerId, viewer.avatarCacheKey + ) + ) { + viewer.avatarCacheKey?.let { + mediaRepository?.peerInlineThumbJpeg( + viewer.peerId, it + ) + } }, ) Column(modifier = Modifier.weight(1f)) { @@ -363,7 +382,10 @@ fun seenByLabelFor( seenBy: (Int) -> String, playedBy: (Int) -> String, ): String? = when (viewers) { - null, MessageViewers.Loading -> null + null, MessageViewers.Loading -> { + null + } + is MessageViewers.Ready -> { val list = viewers.viewers val single = list.singleOrNull() @@ -375,7 +397,9 @@ fun seenByLabelFor( } } - MessageViewers.Expired, MessageViewers.TooBig, MessageViewers.Unavailable -> null + MessageViewers.Expired, MessageViewers.TooBig, MessageViewers.Unavailable -> { + null + } } @Composable @@ -412,7 +436,11 @@ private fun CollapsedViewerRow( AppMenuAvatar( label = viewer.title.orEmpty(), imageFile = viewerAvatar(viewer), - previewBytes = mediaRepository?.peerInlineThumbJpeg(viewer.peerId), + previewBytes = viewer.avatarCacheKey?.let { + mediaRepository?.peerInlineThumbJpeg( + viewer.peerId, it + ) + }, ) }, ) @@ -462,8 +490,16 @@ private fun ExpandedViewerList( title = viewer.title.orEmpty(), size = 40.dp, imageFile = viewerAvatar(viewer), - previewBytes = rememberStrippedPreview(viewer.peerId) { - mediaRepository?.peerInlineThumbJpeg(viewer.peerId) + previewBytes = rememberStrippedPreview( + Triple( + mediaRepository, viewer.peerId, viewer.avatarCacheKey + ) + ) { + viewer.avatarCacheKey?.let { + mediaRepository?.peerInlineThumbJpeg( + viewer.peerId, it + ) + } }, ) }, @@ -484,20 +520,40 @@ fun OutboxReadRow( ) { val yesterday = stringResource(R.string.dialog_yesterday) val label = when (state) { - null -> return - OutboxReadState.Loading -> stringResource(R.string.dialog_seen_loading) - is OutboxReadState.Read -> + null -> { + return + } + + OutboxReadState.Loading -> { + stringResource(R.string.dialog_seen_loading) + } + + is OutboxReadState.Read -> { stringResource( R.string.dialog_read_at, - formatSeenDate(state.date, yesterdayLabel = yesterday) + formatSeenDate(state.date, yesterdayLabel = yesterday), ) + } - OutboxReadState.Unread -> stringResource(R.string.dialog_read_unread) - OutboxReadState.Expired -> stringResource(R.string.dialog_read_receipts_expired) - OutboxReadState.PeerPrivacyHidden -> stringResource(R.string.dialog_read_unknown) + OutboxReadState.Unread -> { + stringResource(R.string.dialog_read_unread) + } - OutboxReadState.MyPrivacyHidden -> stringResource(R.string.dialog_read_plain) - OutboxReadState.Unavailable -> return + OutboxReadState.Expired -> { + stringResource(R.string.dialog_read_receipts_expired) + } + + OutboxReadState.PeerPrivacyHidden -> { + stringResource(R.string.dialog_read_unknown) + } + + OutboxReadState.MyPrivacyHidden -> { + stringResource(R.string.dialog_read_plain) + } + + OutboxReadState.Unavailable -> { + return + } } val clickable = state is OutboxReadState.MyPrivacyHidden && onOpenPrivacy != null AppMenuSurface(modifier = modifier) { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/SwipeToReply.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/SwipeToReply.kt index 8baf47eab..c45c6f9b4 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/SwipeToReply.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/SwipeToReply.kt @@ -55,12 +55,18 @@ internal fun SwipeToReply( ) val replyLabel = stringResource(R.string.dialog_reply) Box( - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .semantics { - if (enabled) customActions = listOf(CustomAccessibilityAction(replyLabel) { - latestReply() - true - }) + if (enabled) { + customActions = + listOf( + CustomAccessibilityAction(replyLabel) { + latestReply() + true + }, + ) + } } .pointerInput(enabled, direction, threshold) { if (!enabled) return@pointerInput @@ -75,12 +81,16 @@ internal fun SwipeToReply( val delta = change.position - down.position if (!dragging) { if (!change.pressed) break - if (abs(delta.y) > viewConfiguration.touchSlop && abs(delta.y) >= abs(delta.x)) break + if (abs(delta.y) > viewConfiguration.touchSlop && abs(delta.y) >= abs( + delta.x + ) + ) break if (abs(delta.x) <= viewConfiguration.touchSlop) continue if (delta.x * direction <= 0f) break dragging = true } - val distance = (delta.x * direction - viewConfiguration.touchSlop).coerceAtLeast(0f) + val distance = + (delta.x * direction - viewConfiguration.touchSlop).coerceAtLeast(0f) offset = distance.coerceAtMost(threshold * 1.35f) * direction val ready = replySwipeReady(distance, threshold) if (ready && !crossed) haptics.performHapticFeedback(HapticFeedbackType.LongPress) @@ -103,13 +113,20 @@ internal fun SwipeToReply( imageVector = Icons.AutoMirrored.Outlined.Reply, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.align(Alignment.CenterEnd).padding(end = 20.dp) - .graphicsLayer { alpha = (abs(translation) / threshold).coerceIn(0f, 1f) }, + modifier = + Modifier + .align(Alignment.CenterEnd) + .padding(end = 20.dp) + .graphicsLayer { alpha = (abs(translation) / threshold).coerceIn(0f, 1f) }, ) } - Box(Modifier.fillMaxWidth().graphicsLayer { translationX = translation }) { content() } + Box(Modifier + .fillMaxWidth() + .graphicsLayer { translationX = translation }) { content() } } } -internal fun replySwipeReady(distance: Float, threshold: Float): Boolean = - threshold > 0f && distance >= threshold +internal fun replySwipeReady( + distance: Float, + threshold: Float, +): Boolean = threshold > 0f && distance >= threshold diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/TextWithTimestampLayout.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/TextWithTimestampLayout.kt index 41279f7a6..55009e73d 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/TextWithTimestampLayout.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/TextWithTimestampLayout.kt @@ -69,17 +69,18 @@ internal fun TextWithTimestampLayout( val timestampHeight = timestampPlaceable.height val textPlaceable = textMeasurables.first().measure(constraints.copy(minWidth = 0)) - val placement = calculateFooterPlacement( - textLayoutInfo = textLayoutInfo, - textWidth = textPlaceable.width, - textHeight = textPlaceable.height, - footerWidth = timestampWidth, - footerHeight = timestampHeight, - minWidth = constraints.minWidth, - maxWidth = constraints.maxWidth, - horizontalPadding = horizontalPaddingPx, - stackedTopPadding = stackedTopPaddingPx, - ) + val placement = + calculateFooterPlacement( + textLayoutInfo = textLayoutInfo, + textWidth = textPlaceable.width, + textHeight = textPlaceable.height, + footerWidth = timestampWidth, + footerHeight = timestampHeight, + minWidth = constraints.minWidth, + maxWidth = constraints.maxWidth, + horizontalPadding = horizontalPaddingPx, + stackedTopPadding = stackedTopPaddingPx, + ) layout(placement.layoutWidth, placement.layoutHeight) { textPlaceable.place(x = 0, y = 0) @@ -107,10 +108,11 @@ internal fun calculateFooterPlacement( if (textLayoutInfo != null) { val lastLineBottom = ceil(textLayoutInfo.lastLineBottom).toInt() - val footerY = (lastLineBottom - footerHeight).coerceIn( - minimumValue = 0, - maximumValue = max(textHeight - footerHeight, 0), - ) + val footerY = + (lastLineBottom - footerHeight).coerceIn( + minimumValue = 0, + maximumValue = max(textHeight - footerHeight, 0), + ) if (textLayoutInfo.lastLineDirection == ResolvedTextDirection.Rtl) { val footerX = floor(textLayoutInfo.lastLineLeft).toInt() - horizontalPadding - footerWidth diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt index fe29502a5..80341e0e1 100644 --- a/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt @@ -1,20 +1,18 @@ package org.monogram.feature.dialog import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory -import kotlinx.coroutines.async -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.flow.first import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.map -import org.monogram.core.models.CloudDraft -import org.monogram.core.models.CloudDraftState import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -26,10 +24,10 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.coroutines.yield import org.junit.After -import org.junit.Assert.assertNull -import org.junit.Assert.assertNotNull import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -40,6 +38,8 @@ import org.monogram.core.database.draftMetaKey import org.monogram.core.markup.FakeMarkupParser import org.monogram.core.models.AuthState import org.monogram.core.models.Chat +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.CloudDraftState import org.monogram.core.models.ContactsSearch import org.monogram.core.models.Folder import org.monogram.core.models.ForumTopic @@ -1006,6 +1006,43 @@ class DialogForumStoreTest { store.dispose() } + @Test + fun deletedMessagesLeaveStateWithoutRepeatingCommittedDeletesAndPersistLiveDeletes() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + for (persisted in listOf(true, false)) { + val chatId = PeerId(5) + val client = FakeClient().apply { history = listOf(topicMessage(10), topicMessage(9)) } + val warmup = FakeWarmup() + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup, sessionStore = null, + chatId = chatId, seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + assertEquals(setOf(9, 10), store.state.messages.map { it.id.id }.toSet()) + assertTrue(warmup.bulkDeletes.isEmpty()) + if (persisted) { + warmup.storedMessages[chatId.value] = listOf(client.topicMessage(9)) + } + val deletion = MtprotoUpdate.MessagesDeleted(chatId, listOf(10)) + assertFalse(deletion.persisted) + client.events.emit(deletion.copy(persisted = persisted)) + advanceUntilIdle() + assertEquals(listOf(9), store.state.messages.map { it.id.id }) + assertEquals( + if (persisted) emptyList() else listOf(chatId to listOf(10)), + warmup.bulkDeletes, + ) + assertEquals( + listOf(9), + warmup.storedMessages[chatId.value].orEmpty().map { it.id.id }) + } finally { + store.dispose() + } + } + } + @Test fun incomingMessagesAreReadWithZeroUnreadCountAndDeduplicated() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) @@ -3481,7 +3518,7 @@ class DialogForumStoreTest { ) assertNull(store.state.editing) assertEquals(4, client.todoCalls) - val mention = org.monogram.core.models.TextEntity("mention_name", 0, 12, "42") + val mention = TextEntity("mention_name", 0, 12, "42") store.accept(DialogStore.Intent.Edit(store.state.messages.single())) store.accept(DialogStore.Intent.DraftChanged("@fi")) store.accept( @@ -3566,7 +3603,7 @@ class DialogForumStoreTest { store.accept( DialogStore.Intent.ReplyTo( Message( - org.monogram.core.models.MessageId( + MessageId( PeerId(5), 42 ), null, "Reply", 1, outgoing = false @@ -3814,6 +3851,84 @@ class DialogForumStoreTest { } } + @Test + fun failedOlderPageRetriesBeforeUncoveredCachedRows() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient().apply { + history = (101..180).reversed().map { topicMessage(it) } + historyPagesByOffset = mapOf( + 101 to (61..100).reversed().map { topicMessage(it) }, + 50 to emptyList(), + ) + historyPageError = "offline" + } + val warmup = FakeWarmup().apply { + olderSlice = org.monogram.core.database.HistorySlice( + (50..60).reversed().map { client.topicMessage(it) }, + false, listOf(61L..100L), false, false, true, + ) + } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = warmup, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + store.accept(DialogStore.Intent.LoadOlder) + advanceUntilIdle() + assertEquals(101, client.lastHistoryOffsetId) + assertTrue(store.state.hasOlder) + assertFalse(store.state.loadingOlder) + client.historyPageError = null + store.accept(DialogStore.Intent.LoadOlder) + advanceUntilIdle() + assertEquals(101, client.lastHistoryOffsetId) + assertTrue(store.state.messages.any { it.id.id == 61 }) + assertTrue(store.state.messages.any { it.id.id == 100 }) + } finally { + store.dispose() + } + } + + @Test + fun coveredOlderPageAdvancesTheNextNetworkBoundary() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient().apply { + history = (101..180).reversed().map { topicMessage(it) } + historyPage = (1..20).reversed().map { topicMessage(it) } + } + val warmup = FakeWarmup().apply { + olderSlice = org.monogram.core.database.HistorySlice( + (21..100).reversed().map { client.topicMessage(it) }, + true, emptyList(), false, false, false, + ) + } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = warmup, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + val previousOffset = client.lastHistoryOffsetId + store.accept(DialogStore.Intent.LoadOlder) + advanceUntilIdle() + assertEquals(previousOffset, client.lastHistoryOffsetId) + assertEquals(21, store.state.messages.minOf { it.id.id }) + assertTrue(store.state.hasOlder) + warmup.olderSlice = null + store.accept(DialogStore.Intent.LoadOlder) + advanceUntilIdle() + assertEquals(21, client.lastHistoryOffsetId) + val ids = store.state.messages.map { it.id.id } + assertEquals((1..180).toList(), ids.sorted()) + assertEquals(ids.size, ids.distinct().size) + } finally { + store.dispose() + } + } + @Test fun sparseCachedOlderRowsDoNotAdvanceTheNetworkCursor() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) @@ -4149,6 +4264,38 @@ class DialogForumStoreTest { } } + @Test + fun channelRecoveryRefreshesOnlyTheMatchingDialog() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient() + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + val calls = client.historyCalls + client.events.emit(MtprotoUpdate.Ignored("channel_difference_too_long:6")) + advanceUntilIdle() + assertEquals(calls, client.historyCalls) + client.events.emit(MtprotoUpdate.Ignored("channel_difference_too_long:5")) + advanceUntilIdle() + assertEquals(calls + 1, client.historyCalls) + store.accept(DialogStore.Intent.Search("needle")) + advanceUntilIdle() + val searchCalls = client.historyCalls + client.events.emit(MtprotoUpdate.Ignored("channel_difference_too_long:5")) + advanceUntilIdle() + assertEquals(searchCalls, client.historyCalls) + store.accept(DialogStore.Intent.Search("")) + advanceUntilIdle() + assertEquals(searchCalls + 1, client.historyCalls) + } finally { + store.dispose() + } + } + @Test fun unmatchedUpdateMessageIdClearsTheOldestPendingSend() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) @@ -4734,6 +4881,72 @@ class DialogForumStoreTest { } } + @Test + fun coveredLatestHistoryCompletesInitialNavigation() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val chatId = PeerId(5) + listOf(0, 90, 10).forEach { jump -> + val rows = (21..100).reversed().map(FakeClient()::topicMessage) + val warmup = FakeWarmup().apply { + storedChats[5] = Chat(chatId, "Dialog", lastMessageId = 100) + storedMessages[5] = rows + latestSlice = org.monogram.core.database.HistorySlice( + rows, true, emptyList(), false, true, true, + ) + } + val client = FakeClient().apply { + historyPage = (1..40).reversed().map(::topicMessage) + } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup, sessionStore = null, + chatId = chatId, jumpToMessageId = jump, seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + if (jump > 0) { + assertEquals(jump, store.state.anchorMessageId) + assertFalse(store.state.anchorAtTop) + } + assertEquals(if (jump == 10) 1 else 0, client.historyCalls) + assertFalse(store.state.loading) + } finally { + store.dispose() + } + } + } + + @Test + fun coveredLatestHistoryContinuesToUnreadOutsideThePaintedWindow() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val chatId = PeerId(5) + val rows = (21..100).reversed().map(FakeClient()::topicMessage) + val warmup = FakeWarmup().apply { + storedChats[5] = Chat( + chatId, "Dialog", lastMessageId = 100, unreadCount = 80, readInboxMaxId = 20, + ) + storedMessages[5] = rows + latestSlice = org.monogram.core.database.HistorySlice( + rows, true, emptyList(), false, true, true, + ) + } + val client = FakeClient() + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup, sessionStore = null, + chatId = chatId, seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + assertEquals(21, store.state.anchorMessageId) + assertTrue(store.state.anchorAtTop) + assertEquals(1, client.historyCalls) + assertEquals(21, client.lastHistoryOffsetId) + } finally { + store.dispose() + } + } + @Test fun openingUnreadDialogAnchorsAtFirstUnreadWithKnownReadBoundary() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) @@ -5164,9 +5377,13 @@ class DialogForumStoreTest { } var newerSliceGate: CompletableDeferred? = null var newerSliceRows: List = emptyList() + var olderSlice: org.monogram.core.database.HistorySlice? = null + var latestSlice: org.monogram.core.database.HistorySlice? = null override suspend fun historySlice( chatId: PeerId, beforeId: Int?, afterId: Int?, limit: Int, ): org.monogram.core.database.HistorySlice { + if (beforeId != null) olderSlice?.let { return it } + if (beforeId == null && afterId == null) latestSlice?.let { return it } if (afterId == null || newerSliceGate == null) return super.historySlice( chatId, beforeId, @@ -5303,6 +5520,15 @@ class DialogForumStoreTest { storedMessages[chatId.value] = storedMessages[chatId.value].orEmpty().filter { it.id.id != messageId } } + val bulkDeletes = mutableListOf>>() + override suspend fun deleteMessages(chatId: PeerId?, messageIds: Collection) { + bulkDeletes += chatId to messageIds.toList() + storedMessages.keys.toList().filter { chatId == null || it == chatId.value } + .forEach { id -> + storedMessages[id] = + storedMessages[id].orEmpty().filterNot { it.id.id in messageIds } + } + } } @Test diff --git a/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileContent.kt b/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileContent.kt index d6926e797..57c004130 100644 --- a/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileContent.kt +++ b/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileContent.kt @@ -62,7 +62,10 @@ private const val ProfilePullRefreshGraceMillis = 700L @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ProfileContent(component: ProfileComponent, modifier: Modifier = Modifier) { +fun ProfileContent( + component: ProfileComponent, + modifier: Modifier = Modifier, +) { val state by component.state.collectAsState() val profile = state.profile val snackbar = remember { SnackbarHostState() } @@ -72,7 +75,7 @@ fun ProfileContent(component: ProfileComponent, modifier: Modifier = Modifier) { var selectedMedia by remember { mutableStateOf(null) } var avatarAttempt by remember(profile?.id?.value) { mutableIntStateOf(0) } val avatarCacheKey = profile?.avatarCacheKey?.let { cacheKey -> - profile.id.let { peerAvatarCacheKey(it, cacheKey) } + peerAvatarCacheKey(profile.id, cacheKey) } val avatarCacheGeneration = rememberCacheGeneration( remember(component.mediaRepository, avatarCacheKey) { @@ -101,8 +104,7 @@ fun ProfileContent(component: ProfileComponent, modifier: Modifier = Modifier) { val listState = rememberLazyListState() val atTop by remember(listState) { derivedStateOf { - listState.firstVisibleItemIndex == 0 && - listState.firstVisibleItemScrollOffset <= ProfileAtTopTolerance + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset <= ProfileAtTopTolerance } } val avatarScale by animateFloatAsState( @@ -171,9 +173,20 @@ fun ProfileContent(component: ProfileComponent, modifier: Modifier = Modifier) { onOpenAvatar = { showAvatar = true }, onRetryAvatar = { avatarAttempt++ }, onMessage = component::onMessage, - onCopyUsername = profile.username?.takeIf { it.isNotBlank() }?.let { handle -> - { scope.launch { clipboard.setClipEntry(ClipEntry(android.content.ClipData.newPlainText("text", "@$handle"))) } } - }, + onCopyUsername = profile.username?.takeIf { it.isNotBlank() } + ?.let { handle -> + { + scope.launch { + clipboard.setClipEntry( + ClipEntry( + android.content.ClipData.newPlainText( + "text", "@$handle" + ) + ), + ) + } + } + }, snackbar = snackbar, clipboard = clipboard, scope = scope, @@ -222,8 +235,13 @@ fun ProfileContent(component: ProfileComponent, modifier: Modifier = Modifier) { internal sealed interface ProfileAvatar { data object Idle : ProfileAvatar + data object Loading : ProfileAvatar - data class Ready(val file: java.io.File) : ProfileAvatar + + data class Ready( + val file: java.io.File, + ) : ProfileAvatar + data object Failed : ProfileAvatar } @@ -236,11 +254,16 @@ private fun rememberProfileAvatar( attempt: Int, ): ProfileAvatar { if (peer == null || cacheKey == null) return ProfileAvatar.Idle - var state by remember(peer, cacheKey, attempt) { mutableStateOf(ProfileAvatar.Loading) } + var state by remember(repository, peer, cacheKey, attempt) { + mutableStateOf( + ProfileAvatar.Loading + ) + } LaunchedEffect(peer, cacheKey, attempt, cacheGeneration, repository) { val repo = repository ?: return@LaunchedEffect val key = peerAvatarCacheKey(peer, cacheKey) val smallKey = peerPhotoSmallCacheKey(key) + fun showCachedFull(): Boolean { val full = repo.cachedFile(key) if (full == null) return false @@ -250,20 +273,32 @@ private fun rememberProfileAvatar( if (showCachedFull()) return@LaunchedEffect repeat(3) { attempt -> if (showCachedFull()) return@LaunchedEffect - if (state !is ProfileAvatar.Ready) { - repo.cachedFile(smallKey)?.let { state = ProfileAvatar.Ready(it) } - ?: repo.cachedAvatar(peer)?.let { state = ProfileAvatar.Ready(it) } + repo.cachedFile(smallKey)?.let { state = ProfileAvatar.Ready(it) } ?: run { + state = ProfileAvatar.Loading } - if (state !is ProfileAvatar.Ready) state = ProfileAvatar.Loading - when (val result = repo.ensureLocalAvatar(peer, key, MediaPriority.USER)) { + when (repo.ensureLocalAvatar( + peer, key, MediaPriority.USER, allowStaleFallback = false + )) { is Outcome.Ok -> { - if (!showCachedFull()) state = ProfileAvatar.Ready(result.value) - return@LaunchedEffect + if (showCachedFull()) return@LaunchedEffect + repo.cachedFile(smallKey)?.let { + state = ProfileAvatar.Ready(it) + return@LaunchedEffect + } + if (attempt == 2) { + state = ProfileAvatar.Failed + } else { + delay(400L * (attempt + 1)) + } } + is Outcome.Err -> { if (showCachedFull() || state is ProfileAvatar.Ready) return@LaunchedEffect - if (attempt == 2) state = ProfileAvatar.Failed - else delay(400L * (attempt + 1)) + if (attempt == 2) { + state = ProfileAvatar.Failed + } else { + delay(400L * (attempt + 1)) + } } } } diff --git a/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileHeader.kt b/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileHeader.kt index 7cb27b25b..b4fb8193c 100644 --- a/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileHeader.kt +++ b/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileHeader.kt @@ -101,8 +101,16 @@ internal fun ProfileHeader( title = profile.title, size = ProfileAvatarSize, imageFile = avatarFile, - previewBytes = rememberStrippedPreview(profile.id) { - mediaRepository?.peerInlineThumbJpeg(profile.id) + previewBytes = rememberStrippedPreview( + Triple( + mediaRepository, profile.id, profile.avatarCacheKey + ) + ) { + profile.avatarCacheKey?.let { + mediaRepository?.peerInlineThumbJpeg( + profile.id, it + ) + } }, ) MonogramLoading( @@ -111,7 +119,9 @@ internal fun ProfileHeader( generation = avatarGeneration, ) if (avatarState is ProfileAvatar.Failed) { - IconButton(onClick = onRetryAvatar, modifier = Modifier.size(ProfileAvatarLoadingSize)) { + IconButton( + onClick = onRetryAvatar, modifier = Modifier.size(ProfileAvatarLoadingSize) + ) { Icon( imageVector = Icons.Outlined.Refresh, contentDescription = stringResource(R.string.profile_retry_avatar), @@ -203,7 +213,15 @@ internal fun ProfileHeader( profile = profile, onCopy = { value -> if (value.isNotBlank()) { - scope.launch { clipboard.setClipEntry(ClipEntry(android.content.ClipData.newPlainText("text", value))) } + scope.launch { + clipboard.setClipEntry( + ClipEntry( + android.content.ClipData.newPlainText( + "text", value + ) + ) + ) + } scope.launch { snackbar.showSnackbar(copiedValue) } } }, @@ -212,17 +230,28 @@ internal fun ProfileHeader( } @Composable -private fun profileSubtitle(profile: Profile, nowMillis: Long): String? { +private fun profileSubtitle( + profile: Profile, + nowMillis: Long, +): String? { peerStatusLabel(profile.status, profile.statusAt, nowMillis)?.let { return it } if (profile.isBot) return stringResource(R.string.profile_kind_bot) return when (profile.kind) { - "channel" -> profile.membersCount?.let { - stringResource(R.string.profile_subscribers_count, it) - } ?: stringResource(R.string.profile_kind_channel) - "chat", "group" -> profile.membersCount?.let { - stringResource(R.string.profile_members_count, it) - } ?: stringResource(R.string.profile_kind_group) - else -> null + "channel" -> { + profile.membersCount?.let { + stringResource(R.string.profile_subscribers_count, it) + } ?: stringResource(R.string.profile_kind_channel) + } + + "chat", "group" -> { + profile.membersCount?.let { + stringResource(R.string.profile_members_count, it) + } ?: stringResource(R.string.profile_kind_group) + } + + else -> { + null + } } } @@ -230,9 +259,12 @@ private fun profileSubtitle(profile: Profile, nowMillis: Long): String? { private fun ProfileStats(profile: Profile) { val items = buildList { when (profile.kind) { - "channel" -> profile.membersCount?.let { - add(it.toString() to stringResource(R.string.profile_subscribers_label)) + "channel" -> { + profile.membersCount?.let { + add(it.toString() to stringResource(R.string.profile_subscribers_label)) + } } + "chat", "group" -> { profile.membersCount?.let { add(it.toString() to stringResource(R.string.profile_members_label)) @@ -290,7 +322,9 @@ private fun ProfileActions( ) { FilledTonalButton( onClick = onMessage, - modifier = Modifier.weight(1f).height(48.dp), + modifier = Modifier + .weight(1f) + .height(48.dp), shapes = ExpressiveDefaults.largeButtonShapes(), contentPadding = ButtonDefaults.ButtonWithIconContentPadding, ) { diff --git a/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileMembers.kt b/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileMembers.kt index 1689a99dc..30a03bb29 100644 --- a/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileMembers.kt +++ b/feature/profile/src/main/java/org/monogram/feature/profile/ui/ProfileMembers.kt @@ -101,7 +101,13 @@ private fun MemberRow( val avatarKey = member.avatarCacheKey?.let { peerAvatarCacheKey(member.id, it) } val avatar = rememberEnsuredFile( generation = rememberCacheGeneration( - remember(repository, avatarKey) { avatarKey?.let { repository?.cacheGeneration(it) } }, + remember(repository, avatarKey) { + avatarKey?.let { + repository?.cacheGeneration( + it + ) + } + }, ), identity = member.id.value to avatarKey, resolve = { avatarKey?.let { repository?.cachedFile(it) } }, @@ -125,8 +131,8 @@ private fun MemberRow( title = member.title, size = 44.dp, imageFile = avatar, - previewBytes = rememberStrippedPreview(member.id) { - repository?.peerInlineThumbJpeg(member.id) + previewBytes = rememberStrippedPreview(Triple(repository, member.id, avatarKey)) { + avatarKey?.let { repository?.peerInlineThumbJpeg(member.id, it) } }, ) Spacer(Modifier.width(12.dp)) @@ -138,8 +144,9 @@ private fun MemberRow( overflow = TextOverflow.Ellipsis, ) val presenceNow = rememberPeerStatusNow(member.status, member.statusAt) - val subtitle = member.username?.let { "@$it" } - ?: peerStatusLabel(member.status, member.statusAt, presenceNow) + val subtitle = member.username?.let { "@$it" } ?: peerStatusLabel( + member.status, member.statusAt, presenceNow + ) if (subtitle != null) { Text( text = subtitle, @@ -200,7 +207,13 @@ private fun CommonGroupRow( val avatarKey = chat.photoCacheKey?.let { peerAvatarCacheKey(chat.id, it) } val avatar = rememberEnsuredFile( generation = rememberCacheGeneration( - remember(repository, avatarKey) { avatarKey?.let { repository?.cacheGeneration(it) } }, + remember(repository, avatarKey) { + avatarKey?.let { + repository?.cacheGeneration( + it + ) + } + }, ), identity = chat.id.value to avatarKey, resolve = { avatarKey?.let { repository?.cachedFile(it) } }, @@ -224,8 +237,8 @@ private fun CommonGroupRow( title = chat.title, size = 44.dp, imageFile = avatar, - previewBytes = rememberStrippedPreview(chat.id) { - repository?.peerInlineThumbJpeg(chat.id) + previewBytes = rememberStrippedPreview(Triple(repository, chat.id, avatarKey)) { + avatarKey?.let { repository?.peerInlineThumbJpeg(chat.id, it) } }, ) Spacer(Modifier.width(12.dp)) diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt index 02274ae07..12dbace25 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt @@ -12,6 +12,7 @@ import com.arkivanov.mvikotlin.core.instancekeeper.getStore import com.arkivanov.mvikotlin.core.store.StoreFactory import com.arkivanov.mvikotlin.extensions.coroutines.labels import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -19,7 +20,10 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.monogram.core.common.MediaCacheLimit import org.monogram.core.common.MediaCacheLimitStore import org.monogram.core.common.Outcome @@ -35,6 +39,14 @@ import org.monogram.network.bridge.MtprotoTransportMode import org.monogram.network.bridge.ProxyConfig import org.monogram.network.http.MediaRepository +data class SettingsAccountDetails( + val title: String?, + val avatarCacheKey: String?, + val mediaRepository: MediaRepository?, + val username: String? = null, + val phone: String? = null, +) + @OptIn(ExperimentalCoroutinesApi::class) class SettingsComponent( componentContext: ComponentContext, @@ -57,6 +69,13 @@ class SettingsComponent( val appUpdate: AppUpdateController? = null, val updatesEnabled: Boolean = true, onBeforeLogout: suspend () -> Unit = {}, + val accountUserIds: List = emptyList(), + private val loadAccountDetails: suspend (Long, suspend (SettingsAccountDetails) -> Unit) -> Unit = { _, _ -> }, + val activeAccountUserId: Long? = null, + val accountNeedsSignInIds: Set = emptySet(), + private val onSwitchAccount: suspend (Long) -> Unit = {}, + private val onRemoveAccount: suspend (Long) -> Unit = {}, + private val onAddAccount: () -> Unit = {}, ) : ComponentContext by componentContext, ProxySession { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) @@ -75,6 +94,10 @@ class SettingsComponent( } val state: StateFlow = store.stateFlow + private val mutableAccountDetails = + MutableStateFlow>(emptyMap()) + val accountDetails: StateFlow> = + mutableAccountDetails.asStateFlow() val passkeysEnabled = MutableStateFlow(false) fun refreshPasskeysEnabled() { scope.launch { @@ -99,6 +122,7 @@ class SettingsComponent( pageNavigation.pop() onDevices(DevicesStore.Intent.Accept(link)) } + val updateState: StateFlow = appUpdate?.state ?: MutableStateFlow(AppUpdateState.Idle) private val notificationsStore = instanceKeeper.getStore("notifications") { @@ -141,6 +165,21 @@ class SettingsComponent( } init { + accountUserIds.distinct().filter { it != activeAccountUserId }.forEach { userId -> + scope.launch { + try { + withContext(Dispatchers.IO) { + loadAccountDetails(userId) { details -> + mutableAccountDetails.update { it + (userId to details) } + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // Keep the cached details if the refresh fails. + } + } + } scope.launch { store.labels.collect { label -> when (label) { @@ -176,6 +215,7 @@ class SettingsComponent( MediaCacheLimitStore.write(directory, limit) mediaRepository?.setCacheLimit(limit.bytes) } + fun onLogout() = store.accept(SettingsStore.Intent.Logout) fun onCheckForUpdates() = appUpdate?.checkForUpdates() fun onDownloadUpdate() = appUpdate?.downloadUpdate() @@ -192,4 +232,11 @@ class SettingsComponent( fun onBack() = onBack.invoke() fun onOpenProfile() = onOpenProfile(PeerId(0L)) fun onNotification(intent: NotificationsStore.Intent) = notificationsStore.accept(intent) + fun switchAccount(userId: Long) { + if (userId == activeAccountUserId) return + scope.launch { onSwitchAccount(userId) } + } + + fun removeAccount(userId: Long) = scope.launch { onRemoveAccount(userId) } + fun addAccount() = onAddAccount() } diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsStore.kt b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsStore.kt index 6bb15e610..f64ddf8c6 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsStore.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsStore.kt @@ -74,22 +74,20 @@ internal class SettingsStoreFactory( mediaRepository?.clearKindCache(kind) ?: 0L }, ) { - fun create(): SettingsStore = - object : - SettingsStore, - Store by storeFactory.create( - name = "SettingsStore", - initialState = SettingsStore.State( - appVersion = appVersion, - buildStamp = buildStamp, - nativeVersion = client.libraryVersion(), - apiLayer = client.apiLayer(), - cacheBytes = mediaRepository?.cacheSizeBytes() ?: 0L, - ), - bootstrapper = SimpleBootstrapper(Unit), - executorFactory = ::ExecutorImpl, - reducer = ReducerImpl, - ) {} + fun create(): SettingsStore = object : SettingsStore, + Store by storeFactory.create( + name = "SettingsStore", + initialState = SettingsStore.State( + appVersion = appVersion, + buildStamp = buildStamp, + nativeVersion = client.libraryVersion(), + apiLayer = client.apiLayer(), + cacheBytes = mediaRepository?.cacheSizeBytes() ?: 0L, + ), + bootstrapper = SimpleBootstrapper(Unit), + executorFactory = ::ExecutorImpl, + reducer = ReducerImpl, + ) {} private sealed interface Msg { data class Loading(val value: Boolean) : Msg @@ -119,12 +117,14 @@ internal class SettingsStoreFactory( mediaRepository?.clearChatCache(intent.chatId) refreshCache() } + is SettingsStore.Intent.ClearKinds -> { kindsToClear(intent.kinds, confirmed = true).forEach { kind -> clearKindCache(kind) } refreshCache() } + SettingsStore.Intent.Logout -> logout() SettingsStore.Intent.ExportDebugStats -> exportDebugStats() SettingsStore.Intent.ClearDebugStats -> { @@ -150,6 +150,7 @@ internal class SettingsStoreFactory( dispatch(Msg.ProfileLoaded(result.value)) sessionStore?.upsertProfile(result.value) } + is Outcome.Err -> { if (state().profile == null) { dispatch(Msg.Error(result.telegramError)) @@ -174,10 +175,15 @@ internal class SettingsStoreFactory( } private fun clearCache() { - val freed = mediaRepository?.clearCache() ?: 0L - ImageCache.clear() - refreshCache() - dispatch(Msg.CacheMessage("cleared:$freed")) + scope.launch { + val freed = withContext(Dispatchers.IO) { + val bytes = mediaRepository?.clearCache() ?: 0L + ImageCache.clear(mediaRepository?.cacheRoot) + bytes + } + refreshCache() + dispatch(Msg.CacheMessage("cleared:$freed")) + } } private fun logout() { @@ -201,8 +207,10 @@ internal class SettingsStoreFactory( } warmup?.clearAccountCache() sessionStore?.clearSession() - mediaRepository?.clearCache() - ImageCache.clear() + withContext(Dispatchers.IO) { + mediaRepository?.clearCache() + ImageCache.clear(mediaRepository?.cacheRoot) + } publish(SettingsStore.Label.LoggedOut) dispatch(Msg.Loading(false)) dispatch(Msg.LoggingOut(false)) diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt index ede793e9e..385e29621 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt @@ -79,7 +79,9 @@ import org.monogram.core.ui.DownloadSettings import org.monogram.core.ui.components.AppBarSyncTitle import org.monogram.core.ui.components.AppStatusBanner import org.monogram.core.ui.components.AppSyncStatus +import org.monogram.core.ui.components.rememberAvatarVideoPoster import org.monogram.core.ui.media.mediaViewerMotionEnabled +import org.monogram.core.ui.rememberCacheGeneration import org.monogram.core.ui.rememberEnsuredFile import org.monogram.core.ui.rememberStrippedPreview import org.monogram.feature.settings.DevicesStore @@ -100,12 +102,14 @@ fun SettingsContent( folders: SettingsFolderHost? = null, ) { val state by component.state.collectAsState() + val accountDetails by component.accountDetails.collectAsStateWithLifecycle() val updateState by component.updateState.collectAsState() val sponsorIds by SponsorRegistry.sponsorIds.collectAsState() val notifications by component.notifications.collectAsState() val appearance by AppearanceSettings.state.collectAsStateWithLifecycle() val download by DownloadSettings.state.collectAsStateWithLifecycle() var confirmLogout by rememberSaveable { mutableStateOf(false) } + var confirmRemoveAccount by remember { mutableStateOf(null) } var confirmClearLogs by rememberSaveable { mutableStateOf(false) } var logQuery by rememberSaveable { mutableStateOf("") } var logKindName by rememberSaveable { mutableStateOf("all") } @@ -143,7 +147,9 @@ fun SettingsContent( predictiveBackAnimatable( initialBackEvent = event, exitModifier = { progress, _ -> - Modifier.graphicsLayer { translationX = size.width * progress * direction } + Modifier.graphicsLayer { + translationX = size.width * progress * direction + } }, enterModifier = { progress, _ -> Modifier.graphicsLayer { @@ -157,16 +163,37 @@ fun SettingsContent( } val pageStates = rememberSaveableStateHolder() val profile = state.profile - val cacheGeneration = component.mediaRepository?.cacheGeneration?.collectAsState()?.value ?: 0L - val selfAvatarPreview = rememberStrippedPreview(profile?.id) { - profile?.id?.let { component.mediaRepository?.peerInlineThumbJpeg(it) } + val cacheGeneration = rememberCacheGeneration( + remember(component.mediaRepository, profile?.id, profile?.avatarCacheKey) { + profile?.id?.let { peer -> + component.mediaRepository?.cacheGeneration( + listOf( + peerAvatarCacheKey(peer, profile.avatarCacheKey), + peerAvatarCacheKey(peer), + ), + ) + } + }, + ) + val selfAvatarPreview = rememberStrippedPreview( + Triple( + component.mediaRepository, + profile?.id, + profile?.avatarCacheKey, + ), + ) { + profile?.id?.let { + component.mediaRepository?.peerInlineThumbJpeg( + it, + profile.avatarCacheKey, + ) + } } val avatarFile = rememberEnsuredFile( generation = cacheGeneration, identity = profile?.id?.value to profile?.avatarCacheKey, resolve = { profile?.avatarCacheKey?.let { component.mediaRepository?.cachedFile(it) } - ?: profile?.id?.let { component.mediaRepository?.cachedAvatar(it) } }, ensure = { val repo = component.mediaRepository ?: return@rememberEnsuredFile null @@ -186,36 +213,81 @@ fun SettingsContent( loaded = profile != null, ) val title = when (page) { - SettingsPage.Home -> stringResource(R.string.settings_title) - SettingsPage.Passkeys -> stringResource(R.string.settings_passkeys) - SettingsPage.Devices -> stringResource(R.string.settings_devices) - SettingsPage.DeviceScanner -> stringResource(R.string.settings_devices_link) - SettingsPage.Proxy -> stringResource(R.string.settings_proxy) - SettingsPage.Data -> stringResource(R.string.settings_data) - SettingsPage.DebugStats -> stringResource(R.string.settings_debug_stats) - SettingsPage.Logs -> stringResource(R.string.settings_logs) - is SettingsPage.AutoDownload -> stringResource( - when (page.network) { - "mobile" -> R.string.settings_autodownload_mobile - "roaming" -> R.string.settings_autodownload_roaming - else -> R.string.settings_autodownload_wifi - }, - ) + SettingsPage.Home -> { + stringResource(R.string.settings_title) + } - SettingsPage.Appearance -> stringResource(R.string.settings_chat) - SettingsPage.Notifications -> stringResource(R.string.settings_notifications) - is SettingsPage.NotificationCategory -> stringResource( - when (page.kind) { - "chats" -> R.string.settings_notifications_groups - "broadcasts" -> R.string.settings_notifications_channels - else -> R.string.settings_notifications_private - }, - ) + SettingsPage.Passkeys -> { + stringResource(R.string.settings_passkeys) + } + + SettingsPage.Devices -> { + stringResource(R.string.settings_devices) + } + + SettingsPage.DeviceScanner -> { + stringResource(R.string.settings_devices_link) + } + + SettingsPage.Proxy -> { + stringResource(R.string.settings_proxy) + } + + SettingsPage.Data -> { + stringResource(R.string.settings_data) + } + + SettingsPage.DebugStats -> { + stringResource(R.string.settings_debug_stats) + } + + SettingsPage.Logs -> { + stringResource(R.string.settings_logs) + } + + is SettingsPage.AutoDownload -> { + stringResource( + when (page.network) { + "mobile" -> R.string.settings_autodownload_mobile + "roaming" -> R.string.settings_autodownload_roaming + else -> R.string.settings_autodownload_wifi + }, + ) + } + + SettingsPage.Appearance -> { + stringResource(R.string.settings_chat) + } + + SettingsPage.Notifications -> { + stringResource(R.string.settings_notifications) + } + + is SettingsPage.NotificationCategory -> { + stringResource( + when (page.kind) { + "chats" -> R.string.settings_notifications_groups + "broadcasts" -> R.string.settings_notifications_channels + else -> R.string.settings_notifications_private + }, + ) + } + + SettingsPage.NotificationExceptions -> { + stringResource(R.string.settings_notifications_exceptions) + } + + SettingsPage.NotificationDebug -> { + stringResource(R.string.settings_notifications_debug) + } - SettingsPage.NotificationExceptions -> stringResource(R.string.settings_notifications_exceptions) - SettingsPage.NotificationDebug -> stringResource(R.string.settings_notifications_debug) - SettingsPage.Wallpaper -> stringResource(R.string.settings_wallpaper) - SettingsPage.Folders -> folders?.title ?: stringResource(R.string.settings_folders) + SettingsPage.Wallpaper -> { + stringResource(R.string.settings_wallpaper) + } + + SettingsPage.Folders -> { + folders?.title ?: stringResource(R.string.settings_folders) + } } Scaffold( @@ -340,157 +412,198 @@ fun SettingsContent( animation = pageAnimation, ) { child -> pageStates.SaveableStateProvider(settingsPageKey(child.instance)) { - Box(Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface)) { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { when (val visiblePage = child.instance) { - SettingsPage.Passkeys -> PasskeysSettings(component, Modifier.padding(innerPadding)) - SettingsPage.Devices -> DevicesSettings( - component, - Modifier.padding(innerPadding), - ) - SettingsPage.DeviceScanner -> DeviceQrScanner( - onScanned = component::onDeviceScanned, - onBack = component::popPage, - modifier = Modifier, - ) - SettingsPage.Home -> SettingsPageList(innerPadding) { - if (state.error != null) { - item { - AppStatusBanner( - sync = AppSyncStatus.Hidden, - error = state.error, - onRetry = component::onRefresh, - ) - } - } - homeItems( - component = component, - stateTitle = accountTitle, - stateSubtitle = accountSubtitle, - avatarFile = avatarFile, - avatarPreview = selfAvatarPreview, - appVersion = state.appVersion, - buildStamp = state.buildStamp, - onOpen = component::openPage, - onLogout = { confirmLogout = true }, - loading = state.loading || state.loggingOut, - sponsorIds = sponsorIds, - selfPeerId = state.profile?.id?.value, - updateState = updateState, - updatesEnabled = component.updatesEnabled, - devicesSubtitle = activeDeviceCount.sessions - .count { !it.current && !it.passwordPending } - .takeIf { it > 0 }, - apiLayer = state.apiLayer, + SettingsPage.Passkeys -> { + PasskeysSettings( + component, + Modifier.padding(innerPadding), ) } - SettingsPage.Proxy -> ProxySettings( - component, - Modifier.padding(innerPadding) - ) - - SettingsPage.Data -> SettingsPageList(innerPadding) { - dataItems( - cacheBytes = state.cacheBytes, - cacheByKind = state.cacheByKind, - cacheChats = state.cacheChats, - cacheMessage = state.cacheMessage, - loading = state.loading || state.loggingOut, - download = download, - onOpenAutoDownload = { network -> - component.openPage( - SettingsPage.AutoDownload(autoDownloadNetworkKey(network)), - ) - }, - cacheLimitLabel = cacheLimitText, - onPickLimit = { pickingLimit = true }, - onClear = { - clearSelection = selectableCacheKinds(state.cacheByKind).toSet() - }, - onClearChat = { confirmChatId = it }, - onOpenDebugStats = if (DebugStats.enabled) { - { component.openPage(SettingsPage.DebugStats) } - } else { - null - }, - onOpenLogs = if (DebugLog.enabled) { - { component.openPage(SettingsPage.Logs) } - } else { - null - }, - mediaRepository = component.mediaRepository, + SettingsPage.Devices -> { + DevicesSettings( + component, + Modifier.padding(innerPadding), ) } - SettingsPage.Logs -> SettingsLogs( - query = logQuery, - kind = debugLogKind(logKindName), - shareFailed = logShareFailed, - onQuery = { - logQuery = it - logShareFailed = false - }, - onKind = { kind -> - logKindName = kind?.name?.lowercase() ?: "all" - logShareFailed = false - }, - modifier = Modifier.padding(innerPadding), - ) - - SettingsPage.DebugStats -> SettingsPageList(innerPadding) { - val expanded = - debugStatsExpanded.split(',').filter { it.isNotEmpty() }.toSet() - debugStatsItems( - exportMessage = state.debugExportMessage, - expanded = expanded, - onToggleSection = { key -> - debugStatsExpanded = if (key in expanded) { - expanded.minus(key).joinToString(",") - } else { - expanded.plus(key).joinToString(",") - } - }, - onExport = component::onExportDebugStats, - onClear = component::onClearDebugStats, + SettingsPage.DeviceScanner -> { + DeviceQrScanner( + onScanned = component::onDeviceScanned, + onBack = component::popPage, + modifier = Modifier, ) } - is SettingsPage.AutoDownload -> SettingsPageList(innerPadding) { - autoDownloadItems( - network = parseAutoDownloadNetwork(visiblePage.network), - preset = download.presetFor(parseAutoDownloadNetwork(visiblePage.network)), - ) + SettingsPage.Home -> { + val avatarPoster = + rememberAvatarVideoPoster(avatarFile, cacheGeneration) + SettingsPageList(innerPadding) { + if (state.error != null) { + item { + AppStatusBanner( + sync = AppSyncStatus.Hidden, + error = state.error, + onRetry = component::onRefresh, + ) + } + } + homeItems( + component = component, + stateTitle = accountTitle, + stateSubtitle = accountSubtitle, + avatarFile = avatarFile, + avatarPreview = selfAvatarPreview, + avatarPoster = avatarPoster, + appVersion = state.appVersion, + buildStamp = state.buildStamp, + onOpen = component::openPage, + onLogout = { confirmLogout = true }, + loading = state.loading || state.loggingOut, + sponsorIds = sponsorIds, + selfPeerId = state.profile?.id?.value, + updateState = updateState, + updatesEnabled = component.updatesEnabled, + devicesSubtitle = activeDeviceCount.sessions.count { !it.current && !it.passwordPending } + .takeIf { it > 0 }, + apiLayer = state.apiLayer, + accountUserIds = component.accountUserIds, + accountDetails = accountDetails, + activeAccountUserId = component.activeAccountUserId, + accountNeedsSignInIds = component.accountNeedsSignInIds, + onRemoveAccount = { confirmRemoveAccount = it }, + onAddAccount = component::addAccount, + ) + } } - SettingsPage.Appearance -> SettingsPageList(innerPadding) { - appearanceItems( - appearance = appearance, - onOpenWallpaper = { component.openPage(SettingsPage.Wallpaper) }, + SettingsPage.Proxy -> { + ProxySettings( + component, + Modifier.padding(innerPadding), ) } - SettingsPage.Notifications -> SettingsPageList(innerPadding) { - notificationsItems( - component, - notifications, - debug = component.debugNotifications, - onOpenDebug = { component.openPage(SettingsPage.NotificationDebug) }, - onOpenCategory = { kind -> - component.openPage(SettingsPage.NotificationCategory(kind)) + SettingsPage.Data -> { + SettingsPageList(innerPadding) { + dataItems( + cacheBytes = state.cacheBytes, + cacheByKind = state.cacheByKind, + cacheChats = state.cacheChats, + cacheMessage = state.cacheMessage, + loading = state.loading || state.loggingOut, + download = download, + onOpenAutoDownload = { network -> + component.openPage( + SettingsPage.AutoDownload(autoDownloadNetworkKey(network)), + ) + }, + cacheLimitLabel = cacheLimitText, + onPickLimit = { pickingLimit = true }, + onClear = { + clearSelection = + selectableCacheKinds(state.cacheByKind).toSet() + }, + onClearChat = { confirmChatId = it }, + onOpenDebugStats = if (DebugStats.enabled) { + { component.openPage(SettingsPage.DebugStats) } + } else { + null + }, + onOpenLogs = if (DebugLog.enabled) { + { component.openPage(SettingsPage.Logs) } + } else { + null + }, + mediaRepository = component.mediaRepository, + ) + } + } + + SettingsPage.Logs -> { + SettingsLogs( + query = logQuery, + kind = debugLogKind(logKindName), + shareFailed = logShareFailed, + onQuery = { + logQuery = it + logShareFailed = false + }, + onKind = { kind -> + logKindName = kind?.name?.lowercase() ?: "all" + logShareFailed = false }, - onOpenExceptions = { component.openPage(SettingsPage.NotificationExceptions) }, + modifier = Modifier.padding(innerPadding), ) } - is SettingsPage.NotificationCategory -> SettingsPageList(innerPadding) { - notificationCategoryItems( - component, - notifications, - visiblePage.kind, - onOpenExceptions = { component.openPage(SettingsPage.NotificationExceptions) }, - ) + SettingsPage.DebugStats -> { + SettingsPageList(innerPadding) { + val expanded = + debugStatsExpanded.split(',').filter { it.isNotEmpty() }.toSet() + debugStatsItems( + exportMessage = state.debugExportMessage, + expanded = expanded, + onToggleSection = { key -> + debugStatsExpanded = if (key in expanded) { + expanded.minus(key).joinToString(",") + } else { + expanded.plus(key).joinToString(",") + } + }, + onExport = component::onExportDebugStats, + onClear = component::onClearDebugStats, + ) + } + } + + is SettingsPage.AutoDownload -> { + SettingsPageList(innerPadding) { + autoDownloadItems( + network = parseAutoDownloadNetwork(visiblePage.network), + preset = download.presetFor(parseAutoDownloadNetwork(visiblePage.network)), + ) + } + } + + SettingsPage.Appearance -> { + SettingsPageList(innerPadding) { + appearanceItems( + appearance = appearance, + onOpenWallpaper = { component.openPage(SettingsPage.Wallpaper) }, + ) + } + } + + SettingsPage.Notifications -> { + SettingsPageList(innerPadding) { + notificationsItems( + component, + notifications, + debug = component.debugNotifications, + onOpenDebug = { component.openPage(SettingsPage.NotificationDebug) }, + onOpenCategory = { kind -> + component.openPage(SettingsPage.NotificationCategory(kind)) + }, + onOpenExceptions = { component.openPage(SettingsPage.NotificationExceptions) }, + ) + } + } + + is SettingsPage.NotificationCategory -> { + SettingsPageList(innerPadding) { + notificationCategoryItems( + component, + notifications, + visiblePage.kind, + onOpenExceptions = { component.openPage(SettingsPage.NotificationExceptions) }, + ) + } } SettingsPage.NotificationExceptions -> { @@ -512,22 +625,52 @@ fun SettingsContent( } } - SettingsPage.NotificationDebug -> SettingsPageList(innerPadding) { - notificationDebugItems(component, notifications) + SettingsPage.NotificationDebug -> { + SettingsPageList(innerPadding) { + notificationDebugItems(component, notifications) + } } - SettingsPage.Wallpaper -> WallpaperSettings( - component, - Modifier.padding(innerPadding) - ) + SettingsPage.Wallpaper -> { + WallpaperSettings( + component, + Modifier.padding(innerPadding), + ) + } - SettingsPage.Folders -> folders?.content(innerPadding) + SettingsPage.Folders -> { + folders?.content(innerPadding) + } } } } } } + if (confirmRemoveAccount != null) { + AlertDialog( + onDismissRequest = { confirmRemoveAccount = null }, + title = { Text(stringResource(R.string.settings_remove_account)) }, + text = { Text(stringResource(R.string.settings_remove_account_confirm)) }, + confirmButton = { + TextButton( + onClick = { + confirmRemoveAccount?.let(component::removeAccount) + confirmRemoveAccount = null + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { Text(stringResource(R.string.settings_remove_account)) } + }, + dismissButton = { + TextButton(onClick = { confirmRemoveAccount = null }) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) + } + if (confirmLogout || state.loggingOut) { AlertDialog( onDismissRequest = { @@ -682,10 +825,13 @@ private fun settingsPageKey(page: SettingsPage): String = when (page) { SettingsPage.NotificationDebug -> "notifications:debug" } -private class MultiBackHandler(private val handlers: List) : BackHandler { +private class MultiBackHandler( + private val handlers: List, +) : BackHandler { override fun isRegistered(callback: BackCallback): Boolean = handlers.any { it.isRegistered(callback) } override fun register(callback: BackCallback) = handlers.forEach { it.register(callback) } + override fun unregister(callback: BackCallback) = handlers.forEach { it.unregister(callback) } } diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt index 1a823ad14..bfe9b52d1 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt @@ -90,24 +90,26 @@ import org.monogram.network.http.MediaRepository import java.util.Locale @Composable -private fun storageCategoryIcon(kind: String): ImageVector = when (kind) { - FileCache.KIND_PHOTOS -> Icons.Outlined.Image - FileCache.KIND_VIDEOS -> Icons.Outlined.Videocam - FileCache.KIND_FILES -> Icons.AutoMirrored.Outlined.InsertDriveFile - FileCache.KIND_STICKERS -> Icons.Outlined.EmojiEmotions - FileCache.KIND_GIFS -> Icons.Outlined.Gif - else -> Icons.Outlined.Storage -} +private fun storageCategoryIcon(kind: String): ImageVector = + when (kind) { + FileCache.KIND_PHOTOS -> Icons.Outlined.Image + FileCache.KIND_VIDEOS -> Icons.Outlined.Videocam + FileCache.KIND_FILES -> Icons.AutoMirrored.Outlined.InsertDriveFile + FileCache.KIND_STICKERS -> Icons.Outlined.EmojiEmotions + FileCache.KIND_GIFS -> Icons.Outlined.Gif + else -> Icons.Outlined.Storage + } @Composable -private fun storageCategoryColor(kind: String): Color = when (kind) { - FileCache.KIND_PHOTOS -> MaterialTheme.colorScheme.primary - FileCache.KIND_VIDEOS -> MaterialTheme.colorScheme.tertiary - FileCache.KIND_FILES -> MaterialTheme.colorScheme.secondary - FileCache.KIND_STICKERS -> MaterialTheme.colorScheme.error - FileCache.KIND_GIFS -> MaterialTheme.colorScheme.onPrimaryContainer - else -> MaterialTheme.colorScheme.onSurfaceVariant -} +private fun storageCategoryColor(kind: String): Color = + when (kind) { + FileCache.KIND_PHOTOS -> MaterialTheme.colorScheme.primary + FileCache.KIND_VIDEOS -> MaterialTheme.colorScheme.tertiary + FileCache.KIND_FILES -> MaterialTheme.colorScheme.secondary + FileCache.KIND_STICKERS -> MaterialTheme.colorScheme.error + FileCache.KIND_GIFS -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } @Composable private fun ChatStorageRow( @@ -119,45 +121,59 @@ private fun ChatStorageRow( ) { val peerId = remember(row.chatId) { PeerId(row.chatId) } val cacheKey = peerAvatarCacheKey(peerId, row.photoCacheKey) - val avatarFile = rememberEnsuredFile( - generation = rememberCacheGeneration( - remember(mediaRepository, peerId, cacheKey) { - mediaRepository?.cacheGeneration(cacheKey) + val avatarFile = + rememberEnsuredFile( + generation = + rememberCacheGeneration( + remember(mediaRepository, peerId, cacheKey) { + mediaRepository?.cacheGeneration(cacheKey) + }, + ), + identity = row.chatId to cacheKey, + resolve = { + mediaRepository?.cachedFile(cacheKey) }, - ), - identity = row.chatId to cacheKey, - resolve = { - mediaRepository?.cachedFile(cacheKey) ?: mediaRepository?.cachedAvatar(peerId) - }, - ensure = { - val repo = mediaRepository ?: return@rememberEnsuredFile null - when (val result = repo.ensureLocalAvatar(peerId, cacheKey, MediaPriority.THUMB)) { - is Outcome.Ok -> result.value - is Outcome.Err -> repo.cachedAvatar(peerId) - } - }, - ) + ensure = { + val repo = mediaRepository ?: return@rememberEnsuredFile null + when (val result = repo.ensureLocalAvatar(peerId, cacheKey, MediaPriority.THUMB)) { + is Outcome.Ok -> result.value + is Outcome.Err -> repo.cachedAvatar(peerId) + } + }, + ) Surface( color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = when (position) { - ItemPosition.TOP -> RoundedCornerShape( - topStart = SettingsGroupCorner, - topEnd = SettingsGroupCorner, - ) + shape = + when (position) { + ItemPosition.TOP -> { + RoundedCornerShape( + topStart = SettingsGroupCorner, + topEnd = SettingsGroupCorner, + ) + } - ItemPosition.MIDDLE -> androidx.compose.foundation.shape.RoundedCornerShape(0.dp) - ItemPosition.BOTTOM -> RoundedCornerShape( - bottomStart = SettingsGroupCorner, - bottomEnd = SettingsGroupCorner, - ) + ItemPosition.MIDDLE -> { + androidx.compose.foundation.shape + .RoundedCornerShape(0.dp) + } - ItemPosition.STANDALONE -> - androidx.compose.foundation.shape.RoundedCornerShape(SettingsGroupCorner) - }, - modifier = Modifier - .fillMaxWidth() - .alpha(if (enabled) 1f else 0.5f) - .clickable(enabled = enabled, onClick = onClick), + ItemPosition.BOTTOM -> { + RoundedCornerShape( + bottomStart = SettingsGroupCorner, + bottomEnd = SettingsGroupCorner, + ) + } + + ItemPosition.STANDALONE -> { + androidx.compose.foundation.shape + .RoundedCornerShape(SettingsGroupCorner) + } + }, + modifier = + Modifier + .fillMaxWidth() + .alpha(if (enabled) 1f else 0.5f) + .clickable(enabled = enabled, onClick = onClick), ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), @@ -167,9 +183,10 @@ private fun ChatStorageRow( title = row.title, size = 40.dp, imageFile = avatarFile, - previewBytes = rememberStrippedPreview(peerId) { - mediaRepository?.peerInlineThumbJpeg(peerId) - }, + previewBytes = + rememberStrippedPreview(Triple(mediaRepository, peerId, cacheKey)) { + mediaRepository?.peerInlineThumbJpeg(peerId, cacheKey) + }, ) Spacer(Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { @@ -208,20 +225,22 @@ internal fun LazyListScope.dataItems( val stickers = cacheByKind[FileCache.KIND_STICKERS] ?: 0L val gifs = cacheByKind[FileCache.KIND_GIFS] ?: 0L val other = cacheByKind[FileCache.KIND_OTHER] ?: 0L - val slices = listOf( - storageCategoryColor(FileCache.KIND_PHOTOS) to photos, - storageCategoryColor(FileCache.KIND_VIDEOS) to videos, - storageCategoryColor(FileCache.KIND_FILES) to files, - storageCategoryColor(FileCache.KIND_STICKERS) to stickers, - storageCategoryColor(FileCache.KIND_GIFS) to gifs, - storageCategoryColor(FileCache.KIND_OTHER) to other, - ).filter { it.second > 0L } + val slices = + listOf( + storageCategoryColor(FileCache.KIND_PHOTOS) to photos, + storageCategoryColor(FileCache.KIND_VIDEOS) to videos, + storageCategoryColor(FileCache.KIND_FILES) to files, + storageCategoryColor(FileCache.KIND_STICKERS) to stickers, + storageCategoryColor(FileCache.KIND_GIFS) to gifs, + storageCategoryColor(FileCache.KIND_OTHER) to other, + ).filter { it.second > 0L } Column( - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.extraLarge) - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .padding(24.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.extraLarge) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( @@ -257,9 +276,10 @@ internal fun LazyListScope.dataItems( onClick = onClear, enabled = !loading && selectableCacheKinds(cacheByKind).isNotEmpty(), shapes = ButtonDefaults.shapes(), - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 56.dp), + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 56.dp), ) { MonogramLoading( visible = loading, @@ -290,21 +310,23 @@ internal fun LazyListScope.dataItems( item(key = "cache-kind:$kind") { val bytes = cacheByKind[kind] ?: 0L SettingsCard( - position = when (index) { - 0 -> ItemPosition.TOP - storageKinds.lastIndex -> ItemPosition.BOTTOM - else -> ItemPosition.MIDDLE - }, + position = + when (index) { + 0 -> ItemPosition.TOP + storageKinds.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + }, ) { SettingsCardRow( icon = storageCategoryIcon(kind), title = stringResource(storageKindLabel(kind)), iconColor = storageCategoryColor(kind), - titleColor = if (bytes > 0L) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + titleColor = + if (bytes > 0L) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, trailingContent = { Text( text = formatBytes(bytes), @@ -333,12 +355,13 @@ internal fun LazyListScope.dataItems( ChatStorageRow( row = row, mediaRepository = mediaRepository, - position = when { - sortedChats.size == 1 -> ItemPosition.STANDALONE - index == 0 -> ItemPosition.TOP - index == sortedChats.lastIndex -> ItemPosition.BOTTOM - else -> ItemPosition.MIDDLE - }, + position = + when { + sortedChats.size == 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == sortedChats.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + }, enabled = !loading, onClick = { onClearChat(row.chatId) }, ) @@ -424,13 +447,14 @@ internal fun LazyListScope.dataItems( SettingsTile( icon = Icons.Outlined.CellTower, title = stringResource(R.string.settings_debug_simulate_network), - subtitle = stringResource( - when (network) { - AutoDownloadNetwork.Wifi -> R.string.settings_debug_simulate_wifi - AutoDownloadNetwork.Mobile -> R.string.settings_debug_simulate_mobile - AutoDownloadNetwork.Roaming -> R.string.settings_debug_simulate_roaming - }, - ), + subtitle = + stringResource( + when (network) { + AutoDownloadNetwork.Wifi -> R.string.settings_debug_simulate_wifi + AutoDownloadNetwork.Mobile -> R.string.settings_debug_simulate_mobile + AutoDownloadNetwork.Roaming -> R.string.settings_debug_simulate_roaming + }, + ), iconColor = MaterialTheme.colorScheme.secondary, position = ItemPosition.STANDALONE, onClick = DownloadSettings::cycleDebugNetwork, @@ -470,27 +494,28 @@ internal fun LazyListScope.dataItems( }, ) } - } -internal fun storageKindLabel(kind: String): Int = when (kind) { - FileCache.KIND_PHOTOS -> R.string.settings_storage_photos - FileCache.KIND_VIDEOS -> R.string.settings_storage_videos - FileCache.KIND_FILES -> R.string.settings_storage_files - FileCache.KIND_STICKERS -> R.string.settings_storage_stickers - FileCache.KIND_GIFS -> R.string.settings_storage_gifs - else -> R.string.settings_storage_other -} +internal fun storageKindLabel(kind: String): Int = + when (kind) { + FileCache.KIND_PHOTOS -> R.string.settings_storage_photos + FileCache.KIND_VIDEOS -> R.string.settings_storage_videos + FileCache.KIND_FILES -> R.string.settings_storage_files + FileCache.KIND_STICKERS -> R.string.settings_storage_stickers + FileCache.KIND_GIFS -> R.string.settings_storage_gifs + else -> R.string.settings_storage_other + } @Composable -internal fun cacheLimitLabel(limit: MediaCacheLimit): String = when (limit) { - MediaCacheLimit.MB_500 -> stringResource(R.string.settings_cache_limit_500) - MediaCacheLimit.GB_1 -> stringResource(R.string.settings_cache_limit_1) - MediaCacheLimit.GB_2 -> stringResource(R.string.settings_cache_limit_2) - MediaCacheLimit.GB_5 -> stringResource(R.string.settings_cache_limit_5) - MediaCacheLimit.GB_10 -> stringResource(R.string.settings_cache_limit_10) - MediaCacheLimit.UNLIMITED -> stringResource(R.string.settings_cache_unlimited) -} +internal fun cacheLimitLabel(limit: MediaCacheLimit): String = + when (limit) { + MediaCacheLimit.MB_500 -> stringResource(R.string.settings_cache_limit_500) + MediaCacheLimit.GB_1 -> stringResource(R.string.settings_cache_limit_1) + MediaCacheLimit.GB_2 -> stringResource(R.string.settings_cache_limit_2) + MediaCacheLimit.GB_5 -> stringResource(R.string.settings_cache_limit_5) + MediaCacheLimit.GB_10 -> stringResource(R.string.settings_cache_limit_10) + MediaCacheLimit.UNLIMITED -> stringResource(R.string.settings_cache_unlimited) + } @Composable internal fun CacheLimitPickerDialog( @@ -505,11 +530,12 @@ internal fun CacheLimitPickerDialog( modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), ) Column( - modifier = Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()) - .selectableGroup() - .padding(horizontal = 16.dp, vertical = 16.dp), + modifier = + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .selectableGroup() + .padding(horizontal = 16.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { MediaCacheLimit.entries.forEach { limit -> @@ -519,11 +545,12 @@ internal fun CacheLimitPickerDialog( icon = Icons.Outlined.Storage, title = cacheLimitLabel(limit), iconColor = MaterialTheme.colorScheme.primary, - modifier = Modifier.selectable( - selected = checked, - role = Role.RadioButton, - onClick = { onSelect(limit) }, - ), + modifier = + Modifier.selectable( + selected = checked, + role = Role.RadioButton, + onClick = { onSelect(limit) }, + ), trailingContent = { RadioButton(selected = checked, onClick = null) }, @@ -557,33 +584,36 @@ internal fun CacheClearDialog( ) } Column( - modifier = Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), ) { val kinds = selectableCacheKinds(byKind) kinds.forEachIndexed { index, kind -> val checked = kind in selected SettingsCard( selected = checked, - position = when { - kinds.size == 1 -> ItemPosition.STANDALONE - index == 0 -> ItemPosition.TOP - index == kinds.lastIndex -> ItemPosition.BOTTOM - else -> ItemPosition.MIDDLE - }, + position = + when { + kinds.size == 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == kinds.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + }, ) { SettingsCardRow( icon = storageCategoryIcon(kind), title = stringResource(storageKindLabel(kind)), subtitle = formatBytes(byKind[kind] ?: 0L), iconColor = MaterialTheme.colorScheme.primary, - modifier = Modifier.toggleable( - value = checked, - role = Role.Checkbox, - onValueChange = { onToggle(kind) }, - ), + modifier = + Modifier.toggleable( + value = checked, + role = Role.Checkbox, + onValueChange = { onToggle(kind) }, + ), trailingContent = { Checkbox(checked = checked, onCheckedChange = null) }, @@ -591,19 +621,21 @@ internal fun CacheClearDialog( } } Text( - text = stringResource( - R.string.settings_clear_cache_selected, - formatBytes(selectedBytes), - ), + text = + stringResource( + R.string.settings_clear_cache_selected, + formatBytes(selectedBytes), + ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp), ) } Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -614,9 +646,10 @@ internal fun CacheClearDialog( onClick = onConfirm, enabled = selectedBytes > 0L, shapes = ButtonDefaults.shapes(), - modifier = Modifier - .weight(1f) - .heightIn(min = 56.dp), + modifier = + Modifier + .weight(1f) + .heightIn(min = 56.dp), ) { Icon(Icons.Outlined.DeleteSweep, contentDescription = null) Spacer(Modifier.width(8.dp)) @@ -645,9 +678,10 @@ internal fun CacheChatClearSheet( modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), ) Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -657,9 +691,10 @@ internal fun CacheChatClearSheet( Button( onClick = onConfirm, shapes = ButtonDefaults.shapes(), - modifier = Modifier - .weight(1f) - .heightIn(min = 56.dp), + modifier = + Modifier + .weight(1f) + .heightIn(min = 56.dp), ) { Text(stringResource(R.string.settings_clear_cache_action)) } @@ -678,33 +713,34 @@ internal fun formatBytes(bytes: Long): String { @Composable internal fun autoDownloadPresetSummary(preset: AutoDownloadPreset): String { if (!preset.enabled) return stringResource(R.string.settings_autodownload_off) - val parts = buildList { - if (preset.photos) add(stringResource(R.string.settings_autodownload_summary_photos)) - if (preset.videos) { - add( - stringResource( - R.string.settings_autodownload_summary_videos, - formatAutoDownloadLimit(preset.maxVideoBytes), - ), - ) - } - if (preset.gifs) { - add( - stringResource( - R.string.settings_autodownload_summary_gifs, - formatAutoDownloadLimit(preset.maxGifBytes), - ), - ) - } - if (preset.files) { - add( - stringResource( - R.string.settings_autodownload_summary_files, - formatAutoDownloadLimit(preset.maxFileBytes), - ), - ) + val parts = + buildList { + if (preset.photos) add(stringResource(R.string.settings_autodownload_summary_photos)) + if (preset.videos) { + add( + stringResource( + R.string.settings_autodownload_summary_videos, + formatAutoDownloadLimit(preset.maxVideoBytes), + ), + ) + } + if (preset.gifs) { + add( + stringResource( + R.string.settings_autodownload_summary_gifs, + formatAutoDownloadLimit(preset.maxGifBytes), + ), + ) + } + if (preset.files) { + add( + stringResource( + R.string.settings_autodownload_summary_files, + formatAutoDownloadLimit(preset.maxFileBytes), + ), + ) + } } - } return parts.joinToString(", ").ifEmpty { stringResource(R.string.settings_autodownload_off) } } diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt index 462b3eb06..eb5239ff6 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt @@ -11,6 +11,8 @@ import androidx.compose.material.icons.automirrored.outlined.Chat import androidx.compose.material.icons.automirrored.outlined.Help import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Campaign import androidx.compose.material.icons.outlined.Devices import androidx.compose.material.icons.outlined.Favorite @@ -33,19 +35,28 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.monogram.core.common.CountryManager +import org.monogram.core.common.Outcome import org.monogram.core.models.AppUpdate import org.monogram.core.models.AppUpdateState +import org.monogram.core.models.PeerId +import org.monogram.core.models.peerAvatarCacheKey import org.monogram.core.ui.AppUpdateSettings +import org.monogram.core.ui.ImageCache import org.monogram.core.ui.components.ItemPosition import org.monogram.core.ui.components.PeerAvatar import org.monogram.core.ui.components.SectionHeader import org.monogram.core.ui.components.SettingsTile +import org.monogram.core.ui.rememberEnsuredFile +import org.monogram.core.ui.rememberStrippedPreview import org.monogram.feature.settings.R +import org.monogram.feature.settings.SettingsAccountDetails import org.monogram.feature.settings.SettingsComponent import org.monogram.feature.settings.SettingsPage import java.io.File @@ -58,6 +69,7 @@ internal fun LazyListScope.homeItems( stateSubtitle: String, avatarFile: File?, avatarPreview: ByteArray? = null, + avatarPoster: Painter? = null, appVersion: String, buildStamp: String, onOpen: (SettingsPage) -> Unit, @@ -69,6 +81,12 @@ internal fun LazyListScope.homeItems( updatesEnabled: Boolean = true, devicesSubtitle: Int? = null, apiLayer: Int = 0, + accountUserIds: List = emptyList(), + accountDetails: Map = emptyMap(), + activeAccountUserId: Long? = null, + accountNeedsSignInIds: Set = emptySet(), + onRemoveAccount: (Long) -> Unit = {}, + onAddAccount: () -> Unit = {}, ) { item { AccountHeader( @@ -76,9 +94,88 @@ internal fun LazyListScope.homeItems( subtitle = stateSubtitle, avatarFile = avatarFile, previewBytes = avatarPreview, + avatarPoster = avatarPoster, onClick = component::onOpenProfile, ) } + item { SectionHeader(stringResource(R.string.settings_section_account)) } + accountUserIds.forEachIndexed { index, userId -> + item(key = "settings-account-$userId") { + val active = userId == activeAccountUserId + val details = accountDetails[userId] + val title = + if (active) { + stateTitle + } else { + details?.title?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.settings_account) + } + val identifier = + if (active) { + stateSubtitle.lineSequence().firstOrNull()?.takeIf { it.isNotBlank() } + } else { + profileSubtitle(details?.username, details?.phone, loaded = true) + .lineSequence() + .firstOrNull() + ?.takeIf { it.isNotBlank() } + } + SettingsTile( + icon = Icons.Outlined.AccountCircle, + title = title, + leadingContent = { + if (active) { + PeerAvatar( + title = title, + size = 40.dp, + imageFile = avatarFile, + previewBytes = avatarPreview, + placeholder = avatarPoster, + ) + } else { + SettingsAccountAvatar( + userId, + title, + details, + userId !in accountNeedsSignInIds, + ) + } + }, + subtitle = + if (userId in accountNeedsSignInIds) { + stringResource(R.string.settings_account_sign_in_required) + } else { + identifier + }, + iconColor = MaterialTheme.colorScheme.primary, + position = if (index == 0) ItemPosition.TOP else ItemPosition.MIDDLE, + selected = active, + titleColor = + if (active) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + onClick = { if (!active) component.switchAccount(userId) }, + onLongClick = + if (accountUserIds.size > 1) { + { onRemoveAccount(userId) } + } else { + null + }, + onLongClickLabel = stringResource(R.string.settings_remove_account), + trailingContent = {}, + ) + } + } + item { + SettingsTile( + icon = Icons.Outlined.Add, + title = stringResource(R.string.settings_add_account), + iconColor = MaterialTheme.colorScheme.primary, + position = if (accountUserIds.isEmpty()) ItemPosition.STANDALONE else ItemPosition.BOTTOM, + onClick = onAddAccount, + ) + } item { SectionHeader(stringResource(R.string.settings_section_settings)) } item { SettingsTile( @@ -128,9 +225,10 @@ internal fun LazyListScope.homeItems( SettingsTile( icon = Icons.Outlined.Devices, title = stringResource(R.string.settings_devices), - subtitle = devicesSubtitle?.let { count -> - pluralStringResource(R.plurals.settings_devices_active_count, count, count) - } ?: stringResource(R.string.settings_devices_sub), + subtitle = + devicesSubtitle?.let { count -> + pluralStringResource(R.plurals.settings_devices_active_count, count, count) + } ?: stringResource(R.string.settings_devices_sub), iconColor = MaterialTheme.colorScheme.secondary, position = ItemPosition.MIDDLE, onClick = { onOpen(SettingsPage.Devices) }, @@ -234,10 +332,14 @@ internal fun LazyListScope.homeItems( SettingsTile( icon = Icons.Outlined.Science, title = stringResource(R.string.settings_update_beta), - subtitle = stringResource( - if (supporter) R.string.settings_update_beta_sub - else R.string.settings_update_beta_supporters, - ), + subtitle = + stringResource( + if (supporter) { + R.string.settings_update_beta_sub + } else { + R.string.settings_update_beta_supporters + }, + ), iconColor = MaterialTheme.colorScheme.tertiary, position = ItemPosition.TOP, enabled = supporter, @@ -283,9 +385,10 @@ internal fun LazyListScope.homeItems( text = changelog.joinToString("\n") { "• $it" }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), ) } } @@ -304,42 +407,49 @@ internal fun LazyListScope.homeItems( ) } item { - val about = buildString { - append(stringResource(R.string.settings_about)) - append(' ') - append(appVersion) - append('\n') - append(buildStamp) - if (apiLayer > 0) { + val about = + buildString { + append(stringResource(R.string.settings_about)) + append(' ') + append(appVersion) append('\n') - append(stringResource(R.string.settings_api_layer, apiLayer)) + append(buildStamp) + if (apiLayer > 0) { + append('\n') + append(stringResource(R.string.settings_api_layer, apiLayer)) + } } - } Text( text = about, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) } } @Composable -private fun updateTileCopy(state: AppUpdateState): Pair { - return when (state) { - AppUpdateState.Idle -> stringResource(R.string.settings_update_check) to - stringResource(R.string.settings_update_check_sub) +private fun updateTileCopy(state: AppUpdateState): Pair = + when (state) { + AppUpdateState.Idle -> { + stringResource(R.string.settings_update_check) to stringResource(R.string.settings_update_check_sub) + } + + AppUpdateState.Checking -> { + stringResource(R.string.settings_update_checking) to null + } - AppUpdateState.Checking -> stringResource(R.string.settings_update_checking) to null is AppUpdateState.Available -> { - val sub = stringResource( - R.string.settings_update_available_sub, - state.info.fileName, - formatUpdateBytes(state.info.fileSize), - ) + val sub = + stringResource( + R.string.settings_update_available_sub, + state.info.fileName, + formatUpdateBytes(state.info.fileSize), + ) val commit = state.info.commit?.takeIf { it.isNotBlank() } stringResource( R.string.settings_update_available, @@ -348,42 +458,59 @@ private fun updateTileCopy(state: AppUpdateState): Pair { ) to if (commit == null) sub else "$sub · $commit" } - AppUpdateState.UpToDate -> stringResource(R.string.settings_update_up_to_date) to - stringResource(R.string.settings_update_up_to_date_sub) + AppUpdateState.UpToDate -> { + stringResource(R.string.settings_update_up_to_date) to + stringResource( + R.string.settings_update_up_to_date_sub, + ) + } is AppUpdateState.Downloading -> { val total = state.info.fileSize.takeIf { it > 0L } ?: 1L val percent = ((state.bytes * 100L) / total).toInt().coerceIn(0, 100) - stringResource(R.string.settings_update_downloading) to stringResource( - R.string.settings_update_downloading_sub, - percent, - formatUpdateBytes(state.info.fileSize), - ) + stringResource(R.string.settings_update_downloading) to + stringResource( + R.string.settings_update_downloading_sub, + percent, + formatUpdateBytes(state.info.fileSize), + ) } - is AppUpdateState.ReadyToInstall -> stringResource(R.string.settings_update_ready) to - stringResource(R.string.settings_update_ready_sub, state.info.version) + is AppUpdateState.ReadyToInstall -> { + stringResource(R.string.settings_update_ready) to + stringResource( + R.string.settings_update_ready_sub, + state.info.version, + ) + } - is AppUpdateState.Installing -> stringResource(R.string.settings_update_installing) to - stringResource(R.string.settings_update_installing_sub, state.info.version) + is AppUpdateState.Installing -> { + stringResource(R.string.settings_update_installing) to + stringResource( + R.string.settings_update_installing_sub, + state.info.version, + ) + } - is AppUpdateState.Error -> stringResource(R.string.settings_update_error) to - if (state.message == AppUpdate.NO_UPDATE) { - stringResource(R.string.settings_update_none) - } else { - stringResource(R.string.settings_update_error_sub) - } + is AppUpdateState.Error -> { + stringResource(R.string.settings_update_error) to + if (state.message == AppUpdate.NO_UPDATE) { + stringResource(R.string.settings_update_none) + } else { + stringResource(R.string.settings_update_error_sub) + } + } } -} private fun updateChangelog(state: AppUpdateState): List { - val info = when (state) { - is AppUpdateState.Available -> state.info - is AppUpdateState.Downloading -> state.info - is AppUpdateState.ReadyToInstall -> state.info - is AppUpdateState.Installing -> state.info - else -> return emptyList() - } + val info = + when (state) { + is AppUpdateState.Available -> state.info + is AppUpdateState.Downloading -> state.info + is AppUpdateState.ReadyToInstall -> state.info + is AppUpdateState.Installing -> state.info + else -> return emptyList() + } return info.changelog.filterNot { line -> val commit = info.commit commit != null && line.equals(commit, ignoreCase = true) @@ -396,6 +523,42 @@ private fun formatUpdateBytes(bytes: Long): String { return if (mb >= 1.0) "${((mb * 10).toInt() / 10.0)} MB" else "${bytes / 1024} KB" } +@Composable +private fun SettingsAccountAvatar( + userId: Long, + title: String, + details: SettingsAccountDetails?, + canDownload: Boolean, +) { + val repo = details?.mediaRepository + if (repo == null) { + PeerAvatar(title = title, size = 40.dp) + return + } + val peerId = PeerId(userId) + val avatarCacheKey = peerAvatarCacheKey(peerId, details.avatarCacheKey) + val generation by repo.cacheGeneration.collectAsStateWithLifecycle() + val identity = Triple(repo, userId, avatarCacheKey) + val preview = + rememberStrippedPreview(identity) { repo.peerInlineThumbJpeg(peerId, avatarCacheKey) } + val file = + rememberEnsuredFile( + generation = generation, + identity = identity, + resolve = { repo.cachedFile(avatarCacheKey) }, + ensure = { + when (val result = repo.ensureLocalAvatar(peerId, avatarCacheKey)) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + }, + demand = canDownload, + ) + ImageCache.AccountScope(repo.cacheRoot) { + PeerAvatar(title = title, size = 40.dp, imageFile = file, previewBytes = preview) + } +} + @Composable private fun Chevron() { Icon( @@ -412,6 +575,7 @@ private fun AccountHeader( subtitle: String, avatarFile: File?, previewBytes: ByteArray?, + avatarPoster: Painter?, onClick: () -> Unit, ) { Surface( @@ -420,26 +584,30 @@ private fun AccountHeader( modifier = Modifier.fillMaxWidth(), ) { ListItem( - supportingContent = subtitle.takeIf { it.isNotBlank() }?.let { - { - Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - }, + supportingContent = + subtitle.takeIf { it.isNotBlank() }?.let { + { + Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, leadingContent = { PeerAvatar( title = title, size = 64.dp, imageFile = avatarFile, previewBytes = previewBytes, + placeholder = avatarPoster, ) }, trailingContent = { Chevron() }, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - colors = ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ), + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = + ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), ) { Text(title, style = MaterialTheme.typography.titleLargeEmphasized) } @@ -448,7 +616,11 @@ private fun AccountHeader( // The signed-in account has no presence to show: Telegram reports its own status too. @Composable -internal fun profileSubtitle(username: String?, phone: String?, loaded: Boolean): String { +internal fun profileSubtitle( + username: String?, + phone: String?, + loaded: Boolean, +): String { val handle = username?.takeIf { it.isNotBlank() }?.let { "@$it" } val number = phone?.takeIf { it.isNotBlank() }?.let(::displayPhone) return when { @@ -462,11 +634,13 @@ internal fun profileSubtitle(username: String?, phone: String?, loaded: Boolean) private fun displayPhone(raw: String): String { val trimmed = raw.trim() - val international = runCatching { CountryManager.formatPhoneNumber(trimmed) }.getOrNull() - ?.takeIf { it.isNotBlank() && it != trimmed } + val international = + runCatching { CountryManager.formatPhoneNumber(trimmed) } + .getOrNull() + ?.takeIf { it.isNotBlank() && it != trimmed } if (international != null) return international val withPlus = if (trimmed.startsWith("+")) trimmed else "+${trimmed.filter { it.isDigit() }}" - return runCatching { CountryManager.formatPhoneNumber(withPlus) }.getOrNull() - ?.takeIf { it.isNotBlank() } - ?: withPlus + return runCatching { CountryManager.formatPhoneNumber(withPlus) } + .getOrNull() + ?.takeIf { it.isNotBlank() } ?: withPlus } diff --git a/feature/settings/src/main/res/values-es/strings.xml b/feature/settings/src/main/res/values-es/strings.xml index 1300db683..a1bc170a9 100644 --- a/feature/settings/src/main/res/values-es/strings.xml +++ b/feature/settings/src/main/res/values-es/strings.xml @@ -25,6 +25,8 @@ Atrás Actualizar Cuenta + Se requiere iniciar sesión + Añadir cuenta Cargando cuenta Cuenta Ajustes @@ -320,6 +322,8 @@ Chats ¿Quitar los archivos descargados de este chat? Aún no hay contenido indexado — abre chats para crear el gráfico + Eliminar cuenta + Se eliminarán la cuenta y sus datos en caché de este dispositivo. La sesión de Telegram no se cerrará. Cerrar sesión Este dispositivo olvidará la sesión de Telegram, los chats y el contenido en caché Cerrando sesión… diff --git a/feature/settings/src/main/res/values-hy/strings.xml b/feature/settings/src/main/res/values-hy/strings.xml index 0c5439902..df2fa75b4 100644 --- a/feature/settings/src/main/res/values-hy/strings.xml +++ b/feature/settings/src/main/res/values-hy/strings.xml @@ -25,6 +25,8 @@ Հետ Թարմացնել Հաշիվ + Պահանջվում է մուտք + Ավելացնել հաշիվ Հաշիվը բեռնվում է Հաշիվ Կարգավորումներ @@ -320,6 +322,8 @@ Զրույցներ Մաքրե՞լ այս զրույցի ներբեռնված ֆայլերը Մեդիան դեռ ինդեքսավորված չէ — բացեք զրույցները գծապատկերը կառուցելու համար + Հեռացնել հաշիվը + Հաշիվն ու դրա քեշավորված տվյալները կհեռացվեն այս սարքից։ Telegram-ի նիստը չի ավարտվի։ Դուրս գալ Այս սարքը կմոռանա Telegram-ի նիստը, քեշավորված զրույցներն ու մեդիան Դուրս գալը… diff --git a/feature/settings/src/main/res/values-ja/strings.xml b/feature/settings/src/main/res/values-ja/strings.xml index acb04a279..31723b103 100644 --- a/feature/settings/src/main/res/values-ja/strings.xml +++ b/feature/settings/src/main/res/values-ja/strings.xml @@ -25,6 +25,8 @@ 戻る 更新 アカウント + ログインが必要です + アカウントを追加 アカウントを読み込み中 アカウント 設定 @@ -320,6 +322,8 @@ チャット このチャットのダウンロード済みファイルを消去しますか? まだメディアが記録されていません — チャットを開いてグラフを作成してください + アカウントを削除 + この端末からアカウントとキャッシュデータを削除します。Telegram のセッションは終了しません。 ログアウト この端末は Telegram のセッション、キャッシュされたチャットとメディアを消去します ログアウト中… diff --git a/feature/settings/src/main/res/values-pt-rBR/strings.xml b/feature/settings/src/main/res/values-pt-rBR/strings.xml index 530ad8b24..78564c1b9 100644 --- a/feature/settings/src/main/res/values-pt-rBR/strings.xml +++ b/feature/settings/src/main/res/values-pt-rBR/strings.xml @@ -25,6 +25,8 @@ Voltar Atualizar Conta + É necessário iniciar sessão + Adicionar conta Carregando conta Conta Configurações @@ -320,6 +322,8 @@ Conversas Limpar os arquivos baixados desta conversa? Nenhuma mídia indexada ainda — abra conversas para montar o gráfico + Remover conta + A conta e seus dados em cache serão removidos deste dispositivo. A sessão do Telegram não será encerrada. Sair Este dispositivo vai esquecer a sessão do Telegram, conversas e mídia em cache Saindo… diff --git a/feature/settings/src/main/res/values-ru/strings.xml b/feature/settings/src/main/res/values-ru/strings.xml index 9ad52daa8..0de747a86 100644 --- a/feature/settings/src/main/res/values-ru/strings.xml +++ b/feature/settings/src/main/res/values-ru/strings.xml @@ -25,6 +25,8 @@ Назад Обновить Аккаунт + Требуется вход + Добавить аккаунт Загрузка аккаунта Аккаунт Настройки @@ -320,6 +322,8 @@ Чаты Очистить скачанные файлы этого чата? Медиа ещё не проиндексировано — откройте чаты, чтобы построить диаграмму + Удалить аккаунт + Аккаунт и его кэшированные данные будут удалены с этого устройства. Сеанс Telegram не будет завершён. Выйти Вы действительно хотите выйти? С устройства будут удалены данные кэша и сессия Выход… diff --git a/feature/settings/src/main/res/values-sk/strings.xml b/feature/settings/src/main/res/values-sk/strings.xml index 3a6662176..5ab03797c 100644 --- a/feature/settings/src/main/res/values-sk/strings.xml +++ b/feature/settings/src/main/res/values-sk/strings.xml @@ -25,6 +25,8 @@ Späť Obnoviť Účet + Vyžaduje sa prihlásenie + Pridať účet Načítava sa účet Účet Nastavenia @@ -320,6 +322,8 @@ Chaty Vymazať stiahnuté súbory tohto chatu? Médiá zatiaľ nie sú indexované — otvorte chaty a vytvorte graf + Odstrániť účet + Účet a jeho údaje v pamäti cache sa odstránia z tohto zariadenia. Relácia Telegramu sa neukončí. Odhlásiť sa Toto zariadenie zabudne reláciu Telegramu, uložené chaty a médiá Odhlasovanie… diff --git a/feature/settings/src/main/res/values-tr/strings.xml b/feature/settings/src/main/res/values-tr/strings.xml index 9350db8cf..1f21803c7 100644 --- a/feature/settings/src/main/res/values-tr/strings.xml +++ b/feature/settings/src/main/res/values-tr/strings.xml @@ -25,6 +25,8 @@ Geri Yenile Hesap + Giriş gerekli + Hesap ekle Hesap yükleniyor Hesap Ayarlar @@ -320,6 +322,8 @@ Sohbetler Bu sohbetin indirilen dosyaları temizlensin mi? Henüz medya dizinlenmedi — grafiği oluşturmak için sohbetleri açın + Hesabı kaldır + Hesap ve önbelleğe alınan verileri bu cihazdan kaldırılacak. Telegram oturumu sonlandırılmayacak. Çıkış yap Bu cihaz Telegram oturumunu, önbellekteki sohbetleri ve medyayı unutacak Çıkış yapılıyor… diff --git a/feature/settings/src/main/res/values-uk/strings.xml b/feature/settings/src/main/res/values-uk/strings.xml index 1f839d5a9..1b69f64f2 100644 --- a/feature/settings/src/main/res/values-uk/strings.xml +++ b/feature/settings/src/main/res/values-uk/strings.xml @@ -25,6 +25,8 @@ Назад Оновити Акаунт + Потрібен вхід + Додати акаунт Завантаження акаунта Акаунт Налаштування @@ -320,6 +322,8 @@ Чати Очистити завантажені файли цього чату? Медіа ще не проіндексовано — відкрийте чати, щоб побудувати діаграму + Видалити обліковий запис + Обліковий запис і кешовані дані буде видалено з цього пристрою. Сесію Telegram не буде завершено. Вийти Цей пристрій забуде сесію Telegram, кешовані чати та медіа Вихід… diff --git a/feature/settings/src/main/res/values-zh/strings.xml b/feature/settings/src/main/res/values-zh/strings.xml index 372f1a1ea..799890461 100644 --- a/feature/settings/src/main/res/values-zh/strings.xml +++ b/feature/settings/src/main/res/values-zh/strings.xml @@ -25,6 +25,8 @@ 返回 刷新 账号 + 需要登录 + 添加账号 正在加载账号 账号 设置 @@ -320,6 +322,8 @@ 聊天 清除此聊天已下载的文件? 尚未统计媒体 — 打开聊天以生成图表 + 移除账号 + 将从此设备移除账号及其缓存数据,但不会终止 Telegram 会话。 退出登录 此设备将清除 Telegram 会话、缓存的聊天和媒体 正在退出… diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 127b4eb0d..8479c705f 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -25,6 +25,8 @@ Back Refresh Account + Sign-in required + Add account Loading account Account Settings @@ -320,6 +322,8 @@ Chats Clear downloaded files for this chat? No media indexed yet — open chats to build the chart + Remove account + This removes the account and its cached data from this device. It does not terminate the Telegram session. Log Out This device will forget the Telegram session, cached chats, and media Signing out… diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e531a54eb..61e706f86 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon Oct 05 18:27:54 MSK 2026 +#Thu Nov 12 19:15:03 MSK 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.9.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/native/Cargo.lock b/native/Cargo.lock index 212ea2004..71397fe01 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -873,11 +873,10 @@ dependencies = [ [[package]] name = "mono-proto" -version = "0.2.0+layer.232" +version = "0.2.0+layer.233" dependencies = [ "bnum", "mono-proto-codec", - "thiserror", ] [[package]] @@ -918,7 +917,6 @@ dependencies = [ "ctr", "getrandom 0.4.3", "num-bigint 0.5.1", - "num-integer", "num-traits", "rsa", "sha1 0.11.0", diff --git a/native/markup/src/main/jniLibs/arm64-v8a/libmonogram_markup.so b/native/markup/src/main/jniLibs/arm64-v8a/libmonogram_markup.so index 4d929c5ef..aa03a4f87 100644 Binary files a/native/markup/src/main/jniLibs/arm64-v8a/libmonogram_markup.so and b/native/markup/src/main/jniLibs/arm64-v8a/libmonogram_markup.so differ diff --git a/native/markup/src/main/jniLibs/armeabi-v7a/libmonogram_markup.so b/native/markup/src/main/jniLibs/armeabi-v7a/libmonogram_markup.so index 03253a5ef..f675c1d97 100644 Binary files a/native/markup/src/main/jniLibs/armeabi-v7a/libmonogram_markup.so and b/native/markup/src/main/jniLibs/armeabi-v7a/libmonogram_markup.so differ diff --git a/native/markup/src/main/jniLibs/x86_64/libmonogram_markup.so b/native/markup/src/main/jniLibs/x86_64/libmonogram_markup.so index f7e652740..685b9fbad 100644 Binary files a/native/markup/src/main/jniLibs/x86_64/libmonogram_markup.so and b/native/markup/src/main/jniLibs/x86_64/libmonogram_markup.so differ diff --git a/native/mtproto-rs/src/dto.rs b/native/mtproto-rs/src/dto.rs index 1b6673dcb..e064ec1e1 100644 --- a/native/mtproto-rs/src/dto.rs +++ b/native/mtproto-rs/src/dto.rs @@ -154,6 +154,8 @@ pub struct ChatDto { pub is_bot: bool, pub is_verified: bool, pub photo_cache_key: Option, + #[uniffi(default = false)] + pub photo_known: bool, pub pinned: bool, pub read_inbox_max_id: i32, pub read_outbox_max_id: i32, @@ -214,6 +216,8 @@ pub struct ProfileDto { pub username: Option, pub about: Option, pub avatar_cache_key: Option, + #[uniffi(default = false)] + pub avatar_known: bool, pub is_self: bool, pub status: Option, pub status_at: Option, @@ -425,6 +429,10 @@ pub enum UpdateEventDto { NewMessage { message: MessageDto, }, + MessageContentInvalidated { + chat_id: i64, + message_id: i32, + }, MessageEdited { message: MessageDto, }, diff --git a/native/mtproto-rs/src/ffi.rs b/native/mtproto-rs/src/ffi.rs index a4e7fe036..a28f250b2 100644 --- a/native/mtproto-rs/src/ffi.rs +++ b/native/mtproto-rs/src/ffi.rs @@ -24,7 +24,7 @@ use crate::{ NotifyExceptionDto, NotifySettingsDto, OutboxReadDto, PollVotersDto, ProfileDto, QrLoginDto, ReactionChoiceDto, ReactionPeersDto, ReadParticipantsDto, ReadReceiptConfigDto, ResolvedPeerDto, SavedGifDto, StickerCatalogDto, StickerListDto, StickerPackDto, - UpdateEventDto, UpdatesStateDto, UploadItemDto, VpxAlphaFrame, VpxFrame, WallpaperCatalogDto, + UpdatesStateDto, UploadItemDto, VpxAlphaFrame, VpxFrame, WallpaperCatalogDto, }; use mono_proto_media as proto_media; use mono_proto_messages as proto_messages; @@ -1307,6 +1307,33 @@ pub fn download_message_media( ) } +#[uniffi::export] +pub fn download_message_media_checked( + handle: u64, + chat_id: i64, + message_id: i32, + dest_path: String, + expected_cache_key: String, + kind: String, + offset: Option, +) -> Result { + let kind = match kind.as_str() { + "thumb" => proto_media::location::MediaDownloadKind::Thumb, + "display" => proto_media::location::MediaDownloadKind::Display, + "full" => proto_media::location::MediaDownloadKind::Full, + _ => return Err(MtprotoError::Message("invalid media download kind".into())), + }; + mono_proto_client::media_download::download_message_media_range_checked( + handle, + chat_id, + message_id, + dest_path, + kind, + offset, + Some(&expected_cache_key), + ) +} + #[uniffi::export] pub fn download_message_media_chunk( handle: u64, diff --git a/native/mtproto-rs/src/lib.rs b/native/mtproto-rs/src/lib.rs index 8bd393806..b051f10d5 100644 --- a/native/mtproto-rs/src/lib.rs +++ b/native/mtproto-rs/src/lib.rs @@ -21,9 +21,7 @@ mod adapter_boundary_tests; use mono_proto_types::collections; -pub(crate) use collections::{ - CompactString, HashMap, HashMapExt, HashSet, HashSetExt, IndexMap, SmallVec, -}; +pub(crate) use collections::{HashMap, HashMapExt}; pub use dto::*; pub use error::*; diff --git a/native/mtproto-rs/src/lottie.rs b/native/mtproto-rs/src/lottie.rs index 9e150a6f7..1a2ad49ce 100644 --- a/native/mtproto-rs/src/lottie.rs +++ b/native/mtproto-rs/src/lottie.rs @@ -118,7 +118,7 @@ pub fn lottie_size(handle: u64) -> Result<(u32, u32), MtprotoError> { } thread_local! { - static LOTTIE_SCRATCH: RefCell> = RefCell::new(Vec::new()); + static LOTTIE_SCRATCH: RefCell> = const { RefCell::new(Vec::new()) }; } /// Renders one frame into a caller-owned RGBA8888 buffer. diff --git a/native/mtproto-rs/src/vpx.rs b/native/mtproto-rs/src/vpx.rs index a577c9850..d46a6a9e6 100644 --- a/native/mtproto-rs/src/vpx.rs +++ b/native/mtproto-rs/src/vpx.rs @@ -5,7 +5,7 @@ use crate::{HashMap, HashMapExt}; use std::os::raw::{c_char, c_int, c_long, c_uint, c_void}; #[cfg(has_libvpx)] use std::ptr; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::AtomicU64; use std::sync::{Arc, LazyLock}; use parking_lot::Mutex; @@ -118,7 +118,7 @@ fn missing() -> MtprotoError { pub fn create_vpx_decoder() -> Result { #[cfg(not(has_libvpx))] { - return Err(MtprotoError::Message("libvpx not linked".into())); + Err(MtprotoError::Message("libvpx not linked".into())) } #[cfg(has_libvpx)] unsafe { @@ -144,7 +144,7 @@ pub fn create_vpx_decoder() -> Result { if err != 0 { return Err(MtprotoError::Message(format!("vpx init failed: {err}"))); } - let id = NEXT.fetch_add(1, Ordering::Relaxed); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); INSTANCES.lock().insert( id, Arc::new(Decoder { @@ -174,7 +174,7 @@ pub fn decode_vpx_packet( #[cfg(not(has_libvpx))] { let _ = (handle, data); - return Err(MtprotoError::Message("libvpx not linked".into())); + Err(MtprotoError::Message("libvpx not linked".into())) } #[cfg(has_libvpx)] with_decoder(handle, |ctx| unsafe { @@ -204,7 +204,7 @@ pub fn decode_vpx_alpha_packet( #[cfg(not(has_libvpx))] { let _ = (handle, data); - return Err(MtprotoError::Message("libvpx not linked".into())); + Err(MtprotoError::Message("libvpx not linked".into())) } #[cfg(has_libvpx)] with_decoder(handle, |ctx| unsafe { @@ -312,8 +312,8 @@ fn image_to_rgba(image: &VpxImage) -> Result { .map_err(|_| MtprotoError::Message("invalid vpx chroma stride".into()))?; let v_stride = usize::try_from(image.stride[2]) .map_err(|_| MtprotoError::Message("invalid vpx chroma stride".into()))?; - let chroma_width = (w + 1) / 2; - let chroma_height = (h + 1) / 2; + let chroma_width = w.div_ceil(2); + let chroma_height = h.div_ceil(2); if y_stride < w || u_stride < chroma_width || v_stride < chroma_width { return Err(MtprotoError::Message("invalid vpx plane stride".into())); } diff --git a/native/mtproto/src/androidTest/java/org/monogram/mtproto/NativeApiContractTest.kt b/native/mtproto/src/androidTest/java/org/monogram/mtproto/NativeApiContractTest.kt index 8fca9e28c..c3f7f09a8 100644 --- a/native/mtproto/src/androidTest/java/org/monogram/mtproto/NativeApiContractTest.kt +++ b/native/mtproto/src/androidTest/java/org/monogram/mtproto/NativeApiContractTest.kt @@ -4,9 +4,9 @@ import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue import org.junit.Assert.assertNotSame import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test import uniffi.monogram_mtproto.CloudDraft import uniffi.monogram_mtproto.MtprotoException @@ -16,15 +16,19 @@ class NativeApiContractTest { fun rebuiltLibrarySmokeTestCoversGeneratedBindingAndLifecycle() { val native = MtprotoNativeLoader.loadOrStub() assertNotSame(MtprotoNative.Stub, native) - assertTrue(native.libraryVersion().contains("layer 232")) - assertEquals(232, native.apiLayer()) + assertTrue(native.libraryVersion().contains("layer 233")) + assertEquals(233, native.apiLayer()) assertNotNull(uniffi.monogram_mtproto.generateQrCode("synthetic-login", 16u)) native.setDownloadChunkKib(512) assertEquals(512, native.downloadChunkKib()) - val session = InstrumentationRegistry.getInstrumentation().targetContext - .cacheDir.resolve("native-api-contract-${System.nanoTime()}.session") + val session = + InstrumentationRegistry + .getInstrumentation() + .targetContext + .cacheDir + .resolve("native-api-contract-${System.nanoTime()}.session") val handle = native.createClient(1, "synthetic-api-hash", session.absolutePath) try { assertTrue(native.clientExists(handle)) @@ -45,137 +49,212 @@ class NativeApiContractTest { fun messagingEntryPointsLoadAndRejectUnknownHandles() { val native = MtprotoNativeLoader.loadOrStub() assertNotSame(MtprotoNative.Stub, native) - assertEquals(232, native.apiLayer()) + assertEquals(233, native.apiLayer()) val draft = CloudDraft(7L, 0, "synthetic draft", null, 0, 0, false, false) - val upload = uniffi.monogram_mtproto.UploadItemDto( - path = "synthetic-unused", - kind = "photo", - mimeType = "image/jpeg", - fileName = "fixture.jpg", - caption = "synthetic caption", - entitiesJson = null, - duration = 0, - width = 0, - height = 0, - randomId = 123L, - ) - val calls: List<() -> Unit> = listOf( - { native.replaceUploadedMedia(0L, 7L, 37, "fixture", upload.copy(caption = "")) }, - { native.sendUploadedMedia(0L, 7L, upload) }, - { native.sendUploadedAlbum(0L, 7L, listOf(upload)) }, - { - native.sendUploadedMediaWithOptions( - 0L, 7L, upload, 0, 0, null, uniffi.monogram_mtproto.SendOptions( - silent = true, noWebpage = false, scheduleDate = 0x7ffffffe + val upload = + uniffi.monogram_mtproto.UploadItemDto( + path = "synthetic-unused", + kind = "photo", + mimeType = "image/jpeg", + fileName = "fixture.jpg", + caption = "synthetic caption", + entitiesJson = null, + duration = 0, + width = 0, + height = 0, + randomId = 123L, + ) + val calls: List<() -> Unit> = + listOf( + { native.replaceUploadedMedia(0L, 7L, 37, "fixture", upload.copy(caption = "")) }, + { native.sendUploadedMedia(0L, 7L, upload) }, + { native.sendUploadedAlbum(0L, 7L, listOf(upload)) }, + { + native.sendUploadedMediaWithOptions( + 0L, + 7L, + upload, + 0, + 0, + null, + uniffi.monogram_mtproto.SendOptions( + silent = true, + noWebpage = false, + scheduleDate = 0x7ffffffe, + ), ) - ) - }, - { - native.sendUploadedAlbumWithOptions( - 0L, 7L, listOf(upload), 0, 0, uniffi.monogram_mtproto.SendOptions( - silent = true, noWebpage = false, scheduleDate = 0x7ffffffe + }, + { + native.sendUploadedAlbumWithOptions( + 0L, + 7L, + listOf(upload), + 0, + 0, + uniffi.monogram_mtproto.SendOptions( + silent = true, + noWebpage = false, + scheduleDate = 0x7ffffffe, + ), ) - ) - }, - { native.getMessageReactionsPage(0L, 7L, 37, "fixture") }, - { native.getPollVotesPage(0L, 7L, 37, "fixture") }, - { - native.sendDiceWithOptions( - 0L, 7L, "🎲", 0, 0, 123L, uniffi.monogram_mtproto.SendOptions( - silent = true, noWebpage = false, scheduleDate = null + }, + { native.getMessageReactionsPage(0L, 7L, 37, "fixture") }, + { native.getPollVotesPage(0L, 7L, 37, "fixture") }, + { + native.sendDiceWithOptions( + 0L, + 7L, + "🎲", + 0, + 0, + 123L, + uniffi.monogram_mtproto.SendOptions( + silent = true, + noWebpage = false, + scheduleDate = null, + ), ) - ) - }, - { native.toggleTodoCompleted(0L, 7L, 37, listOf(0), emptyList()) }, - { native.appendTodoItems(0L, 7L, 37, 1, listOf("Fixture item")) }, - { - native.sendPollWithOptions( - 0L, 7L, uniffi.monogram_mtproto.PollSendSpec( - "Synthetic question", - listOf("First", "Second"), - true, - false, - false, - null, - null - ), 0, 0, 123L, uniffi.monogram_mtproto.SendOptions( - silent = false, noWebpage = false, scheduleDate = null + }, + { native.toggleTodoCompleted(0L, 7L, 37, listOf(0), emptyList()) }, + { native.appendTodoItems(0L, 7L, 37, 1, listOf("Fixture item")) }, + { + native.sendPollWithOptions( + 0L, + 7L, + uniffi.monogram_mtproto.PollSendSpec( + "Synthetic question", + listOf("First", "Second"), + true, + false, + false, + null, + null, + ), + 0, + 0, + 123L, + uniffi.monogram_mtproto.SendOptions( + silent = false, + noWebpage = false, + scheduleDate = null, + ), + ) + }, + { + native.sendChecklistWithOptions( + 0L, + 7L, + uniffi.monogram_mtproto.ChecklistSendSpec( + "Synthetic list", + listOf("First", "Second"), + true, + false, + ), + 0, + 0, + 123L, + uniffi.monogram_mtproto.SendOptions( + silent = false, + noWebpage = false, + scheduleDate = null, + ), ) - ) - }, - { - native.sendChecklistWithOptions( - 0L, 7L, uniffi.monogram_mtproto.ChecklistSendSpec( - "Synthetic list", listOf("First", "Second"), true, false - ), 0, 0, 123L, uniffi.monogram_mtproto.SendOptions( - silent = false, noWebpage = false, scheduleDate = null + }, + { + native.importContacts( + 0L, + listOf( + uniffi.monogram_mtproto.ContactImportSpec( + 123L, + "+15555550123", + "Synthetic", + "Contact", + ), + ), ) - ) - }, - { - native.importContacts( - 0L, listOf( - uniffi.monogram_mtproto.ContactImportSpec( - 123L, "+15555550123", "Synthetic", "Contact" - ) + }, + { + native.sendLocationWithOptions( + 0L, + 7L, + uniffi.monogram_mtproto.LocationSendSpec(0.0, 0.0, 900, null), + 0, + 0, + 123L, + uniffi.monogram_mtproto.SendOptions(false, false, null), ) - ) - }, - { - native.sendLocationWithOptions( - 0L, - 7L, - uniffi.monogram_mtproto.LocationSendSpec(0.0, 0.0, 900, null), - 0, - 0, - 123L, - uniffi.monogram_mtproto.SendOptions(false, false, null) - ) - }, - { - native.sendContactWithOptions( - 0L, 7L, uniffi.monogram_mtproto.ContactSendSpec( - "+15555550123", "Synthetic", "Contact", "" - ), 0, 0, 123L, uniffi.monogram_mtproto.SendOptions( - silent = false, noWebpage = false, scheduleDate = null + }, + { + native.sendContactWithOptions( + 0L, + 7L, + uniffi.monogram_mtproto.ContactSendSpec( + "+15555550123", + "Synthetic", + "Contact", + "", + ), + 0, + 0, + 123L, + uniffi.monogram_mtproto.SendOptions( + silent = false, + noWebpage = false, + scheduleDate = null, + ), ) - ) - }, - { native.editLiveLocation(0L, 7L, 37, null, null, null, true) }, - { - native.removeChecklistItem( - 0L, - 7L, - 37, - uniffi.monogram_mtproto.ChecklistTextEditDto(0, "", null, "Old", null) - ) - }, - { - native.editChecklistText( - 0L, 7L, 37, uniffi.monogram_mtproto.ChecklistTextEditDto( - null, "Synthetic title", null, "Old", null + }, + { native.editLiveLocation(0L, 7L, 37, null, null, null, true) }, + { + native.removeChecklistItem( + 0L, + 7L, + 37, + uniffi.monogram_mtproto.ChecklistTextEditDto(0, "", null, "Old", null), + ) + }, + { + native.editChecklistText( + 0L, + 7L, + 37, + uniffi.monogram_mtproto.ChecklistTextEditDto( + null, + "Synthetic title", + null, + "Old", + null, + ), + ) + }, + { native.editChecklistTitle(0L, 7L, 37, "Synthetic title") }, + { native.closePoll(0L, 7L, 37) }, + { native.getMessage(0L, 7L, 37) }, + { native.getCloudDrafts(0L) }, + { native.saveCloudDraft(0L, draft) }, + { + native.sendTextMessageWithOptions( + 0L, + 7L, + "synthetic draft", + 0, + null, + 0, + null, + 123L, + uniffi.monogram_mtproto.SendOptions( + silent = true, + noWebpage = true, + scheduleDate = 0x7ffffffe, + ), ) - ) - }, - { native.editChecklistTitle(0L, 7L, 37, "Synthetic title") }, - { native.closePoll(0L, 7L, 37) }, - { native.getMessage(0L, 7L, 37) }, - { native.getCloudDrafts(0L) }, - { native.saveCloudDraft(0L, draft) }, - { - native.sendTextMessageWithOptions( - 0L, 7L, "synthetic draft", 0, null, 0, null, 123L, - uniffi.monogram_mtproto.SendOptions( - silent = true, noWebpage = true, scheduleDate = 0x7ffffffe - ), - ) - }, - { native.getScheduledHistory(0L, 7L) }, - { native.getScheduledMessages(0L, 7L, listOf(1)) }, - { native.sendScheduledMessages(0L, 7L, listOf(1)) }, - { native.deleteScheduledMessages(0L, 7L, listOf(1)) }, - { native.editScheduledMessage(0L, 7L, 1, 0x7ffffffe, "replacement", null, false) }, - ) + }, + { native.getScheduledHistory(0L, 7L) }, + { native.getScheduledMessages(0L, 7L, listOf(1)) }, + { native.sendScheduledMessages(0L, 7L, listOf(1)) }, + { native.deleteScheduledMessages(0L, 7L, listOf(1)) }, + { native.editScheduledMessage(0L, 7L, 1, 0x7ffffffe, "replacement", null, false) }, + ) calls.forEach { call -> assertThrows(MtprotoException::class.java) { call() } } } } diff --git a/native/mtproto/src/androidTest/java/org/monogram/mtproto/SessionKeyStoreTest.kt b/native/mtproto/src/androidTest/java/org/monogram/mtproto/SessionKeyStoreTest.kt index 94f68437e..f23565ce2 100644 --- a/native/mtproto/src/androidTest/java/org/monogram/mtproto/SessionKeyStoreTest.kt +++ b/native/mtproto/src/androidTest/java/org/monogram/mtproto/SessionKeyStoreTest.kt @@ -1,16 +1,16 @@ package org.monogram.mtproto import androidx.test.platform.app.InstrumentationRegistry -import java.io.File -import java.security.KeyStore -import java.security.MessageDigest -import java.util.UUID import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows import org.junit.Test +import java.io.File +import java.security.KeyStore +import java.security.MessageDigest +import java.util.UUID class SessionKeyStoreTest { private val directory = File( @@ -18,19 +18,171 @@ class SessionKeyStoreTest { "session-key-test-${UUID.randomUUID()}", ).apply { mkdirs() } private val session = File(directory, "session") + private val promoted = File(directory, "account/session") @After fun cleanUp() { val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - for (prefix in listOf("monogram.session.", "monore.session.")) { - val alias = prefix + MessageDigest.getInstance("SHA-256") - .digest(session.canonicalPath.toByteArray(Charsets.UTF_8)) - .joinToString("") { "%02x".format(it) } - keyStore.deleteEntry(alias) + for (file in listOf(session, promoted)) { + for (prefix in listOf("monogram.session.", "monore.session.")) { + val alias = prefix + MessageDigest.getInstance("SHA-256") + .digest(file.canonicalPath.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + keyStore.deleteEntry(alias) + } } directory.deleteRecursively() } + @Test + fun promotionRewrapsOriginalKeyForDestinationPath() { + val original = SessionKeyStore.loadOrCreate(session.path) + val encrypted = byteArrayOf(0xC3.toByte(), 0xA7.toByte(), 0x5E, 0x01, 42) + session.writeBytes(encrypted) + try { + MtprotoNativeLoader.promoteSession(session, promoted) + assertFalse(session.exists()) + assertArrayEquals(encrypted, promoted.readBytes()) + val restored = SessionKeyStore.loadOrCreate(promoted.path) + try { + assertArrayEquals(original, restored) + } finally { + restored.fill(0) + } + } finally { + original.fill(0) + } + } + + @Test + fun promotionDoesNotOverwriteExistingSession() { + session.writeText("source") + promoted.parentFile!!.mkdirs() + promoted.writeText("existing") + assertThrows(IllegalStateException::class.java) { + MtprotoNativeLoader.promoteSession(session, promoted) + } + assertEquals("source", session.readText()) + assertEquals("existing", promoted.readText()) + } + + @Test + fun recoveryCompletesBeforeRenameAndIsIdempotentAfterRename() { + SessionKeyStore.loadOrCreate(session.path).fill(0) + session.writeText("session data") + MtprotoNativeLoader.promoteSession(session, promoted) + val sourceKey = File("${session.path}.key").readBytes() + val destinationKey = File("${promoted.path}.key").readBytes() + check(promoted.renameTo(session)) + + MtprotoNativeLoader.recoverSessionPromotion(session, promoted) + MtprotoNativeLoader.recoverSessionPromotion(session, promoted) + + assertFalse(session.exists()) + assertEquals("session data", promoted.readText()) + assertArrayEquals(sourceKey, File("${session.path}.key").readBytes()) + assertArrayEquals(destinationKey, File("${promoted.path}.key").readBytes()) + } + + @Test + fun recoveryRejectsDifferentKeysWithoutChangingFiles() { + SessionKeyStore.loadOrCreate(session.path).fill(0) + promoted.parentFile!!.mkdirs() + SessionKeyStore.loadOrCreate(promoted.path).fill(0) + session.writeText("source") + val sourceKey = File("${session.path}.key").readBytes() + val destinationKey = File("${promoted.path}.key").readBytes() + + assertThrows(IllegalStateException::class.java) { + MtprotoNativeLoader.recoverSessionPromotion(session, promoted) + } + assertEquals("source", session.readText()) + assertFalse(promoted.exists()) + assertArrayEquals(sourceKey, File("${session.path}.key").readBytes()) + assertArrayEquals(destinationKey, File("${promoted.path}.key").readBytes()) + } + + @Test + fun recoveryRejectsMissingAndDamagedWrappedKeys() { + SessionKeyStore.loadOrCreate(session.path).fill(0) + session.writeText("source") + assertThrows(IllegalStateException::class.java) { + MtprotoNativeLoader.recoverSessionPromotion(session, promoted) + } + MtprotoNativeLoader.promoteSession(session, promoted) + val keyFile = File("${promoted.path}.key") + val damaged = + keyFile.readBytes().also { it[it.lastIndex] = (it.last().toInt() xor 1).toByte() } + keyFile.writeBytes(damaged) + assertThrows(Exception::class.java) { + MtprotoNativeLoader.recoverSessionPromotion( + session, + promoted + ) + } + assertArrayEquals(damaged, keyFile.readBytes()) + assertEquals("source", promoted.readText()) + } + + @Test + fun recoveryRejectsKeysCopiedBetweenPaths() { + SessionKeyStore.loadOrCreate(session.path).fill(0) + session.writeText("source") + MtprotoNativeLoader.promoteSession(session, promoted) + val copiedKey = File("${session.path}.key").readBytes() + val destinationKey = File("${promoted.path}.key") + destinationKey.writeBytes(copiedKey) + + assertThrows(Exception::class.java) { + MtprotoNativeLoader.recoverSessionPromotion( + session, + promoted + ) + } + assertArrayEquals(copiedKey, destinationKey.readBytes()) + assertEquals("source", promoted.readText()) + } + + @Test + fun recoveryRejectsBackupsAndAmbiguousSessionStates() { + SessionKeyStore.loadOrCreate(session.path).fill(0) + session.writeText("source") + MtprotoNativeLoader.promoteSession(session, promoted) + val backup = File("${session.path}.key.bak") + backup.writeText("backup") + assertThrows(IllegalStateException::class.java) { + MtprotoNativeLoader.recoverSessionPromotion(session, promoted) + } + assertEquals("backup", backup.readText()) + backup.delete() + session.writeText("other") + assertThrows(IllegalStateException::class.java) { + MtprotoNativeLoader.recoverSessionPromotion(session, promoted) + } + assertEquals("other", session.readText()) + assertEquals("source", promoted.readText()) + } + + @Test + fun recoveryRejectsAtomicNewAndMissingSessions() { + SessionKeyStore.loadOrCreate(session.path).fill(0) + session.writeText("source") + MtprotoNativeLoader.promoteSession(session, promoted) + val pending = File("${promoted.path}.key.new") + pending.writeText("pending") + assertThrows(IllegalStateException::class.java) { + MtprotoNativeLoader.recoverSessionPromotion(session, promoted) + } + assertEquals("pending", pending.readText()) + check(pending.renameTo(File(directory, "parked-key"))) + check(promoted.renameTo(File(directory, "parked-session"))) + assertThrows(IllegalStateException::class.java) { + MtprotoNativeLoader.recoverSessionPromotion(session, promoted) + } + assertFalse(session.exists()) + assertFalse(promoted.exists()) + } + @Test fun wrappedKeyRestoresWithoutWritingPlaintextKey() { val first = SessionKeyStore.loadOrCreate(session.path) diff --git a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt index 96c5fdb60..72efab59a 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt @@ -48,41 +48,93 @@ data class NativeJoinResult( interface MtprotoNative { fun passkeysEnabled(handle: Long): Boolean = false - fun initPasskeyLogin(handle: Long): uniffi.monogram_mtproto.PasskeyLoginOptionsDto = throw UnsupportedOperationException("passkeys") - fun finishPasskeyLogin(handle: Long, credential: uniffi.monogram_mtproto.PasskeyCredentialDto, dcId: Int, authKeyId: Long): AuthSignedIn = throw UnsupportedOperationException("passkeys") - fun initPasskeyRegistration(handle: Long): String = throw UnsupportedOperationException("passkeys") - fun registerPasskey(handle: Long, credential: uniffi.monogram_mtproto.PasskeyCredentialDto): uniffi.monogram_mtproto.PasskeyDto = throw UnsupportedOperationException("passkeys") - fun getPasskeys(handle: Long): List = throw UnsupportedOperationException("passkeys") - fun deletePasskey(handle: Long, id: String): Boolean = throw UnsupportedOperationException("passkeys") - - fun exportLoginToken(handle: Long): QrLoginDto = - throw UnsupportedOperationException("QR login") + + fun initPasskeyLogin(handle: Long): uniffi.monogram_mtproto.PasskeyLoginOptionsDto = + throw UnsupportedOperationException("passkeys") + + fun finishPasskeyLogin( + handle: Long, + credential: uniffi.monogram_mtproto.PasskeyCredentialDto, + dcId: Int, + authKeyId: Long, + ): AuthSignedIn = throw UnsupportedOperationException("passkeys") + + fun initPasskeyRegistration(handle: Long): String = + throw UnsupportedOperationException("passkeys") + + fun registerPasskey( + handle: Long, + credential: uniffi.monogram_mtproto.PasskeyCredentialDto, + ): uniffi.monogram_mtproto.PasskeyDto = throw UnsupportedOperationException("passkeys") + + fun getPasskeys(handle: Long): List = + throw UnsupportedOperationException("passkeys") + + fun deletePasskey( + handle: Long, + id: String, + ): Boolean = throw UnsupportedOperationException("passkeys") + + fun exportLoginToken(handle: Long): QrLoginDto = throw UnsupportedOperationException("QR login") + fun pollLoginToken(handle: Long): Boolean = throw UnsupportedOperationException("QR login") fun getDeviceSessions(handle: Long): List = throw UnsupportedOperationException("device sessions") - fun acceptLoginToken(handle: Long, token: ByteArray): DeviceSessionDto = - throw UnsupportedOperationException("QR login") - fun revokeDeviceSession(handle: Long, hash: Long): Unit = - throw UnsupportedOperationException("device session revocation") + + fun acceptLoginToken( + handle: Long, + token: ByteArray, + ): DeviceSessionDto = throw UnsupportedOperationException("QR login") + + fun revokeDeviceSession( + handle: Long, + hash: Long, + ): Unit = throw UnsupportedOperationException("device session revocation") fun setTransportMode(mode: String) = Unit - fun setProxy(kind: String, host: String, port: Int, username: String?, password: String?, secret: ByteArray) = Unit - fun pingProxy(kind: String, host: String, port: Int, username: String?, password: String?, secret: ByteArray): Long = - throw UnsupportedOperationException("proxy ping") + + fun setProxy( + kind: String, + host: String, + port: Int, + username: String?, + password: String?, + secret: ByteArray, + ) = Unit + + fun pingProxy( + kind: String, + host: String, + port: Int, + username: String?, + password: String?, + secret: ByteArray, + ): Long = throw UnsupportedOperationException("proxy ping") + fun clearProxy() = Unit + fun createRequestControl(): Long = 0L + fun bindRequestControl(id: Long): Long = 0L + fun cancelRequestControl(id: Long) = Unit - fun promoteRequestControl(id: Long, dispatchClass: Int) = Unit + fun promoteRequestControl( + id: Long, + dispatchClass: Int, + ) = Unit + fun releaseRequestControl(id: Long) = Unit /** Dispatch class for the request on this thread. */ fun setDispatchClass(classId: Int) = Unit /** Applies the "Faster downloads" setting: media lanes and parts in flight. */ - fun setDownloadConcurrency(lanes: Int, parts: Int) = Unit + fun setDownloadConcurrency( + lanes: Int, + parts: Int, + ) = Unit /** Effective `[lanes, parts]` after native clamping. */ fun downloadConcurrency(): List = listOf(0, 0) @@ -119,10 +171,17 @@ interface MtprotoNative { /** JSON timing snapshot of the Rust side; `reset` clears the window. */ fun perfSnapshot(reset: Boolean): String = "{}" - fun createClient(apiId: Int, apiHash: String, sessionPath: String): Long + fun createClient( + apiId: Int, + apiHash: String, + sessionPath: String, + ): Long @Throws(MtprotoException::class) - fun setTestDc(handle: Long, enabled: Boolean) = Unit + fun setTestDc( + handle: Long, + enabled: Boolean, + ) = Unit fun usesTestDc(handle: Long): Boolean = false @@ -139,11 +198,17 @@ interface MtprotoNative { fun clientApiId(handle: Long): Int = 0 @Throws(MtprotoException::class) - fun sendAuthCode(handle: Long, phone: String): AuthCodeSent + fun sendAuthCode( + handle: Long, + phone: String, + ): AuthCodeSent @Throws(MtprotoException::class) - fun resendAuthCode(handle: Long, phone: String, phoneCodeHash: String): AuthCodeSent = - sendAuthCode(handle, phone) + fun resendAuthCode( + handle: Long, + phone: String, + phoneCodeHash: String, + ): AuthCodeSent = sendAuthCode(handle, phone) @Throws(MtprotoException::class) fun signIn( @@ -154,7 +219,10 @@ interface MtprotoNative { ): AuthSignedIn @Throws(MtprotoException::class) - fun checkPassword(handle: Long, password: String): AuthSignedIn + fun checkPassword( + handle: Long, + password: String, + ): AuthSignedIn @Throws(MtprotoException::class) fun logout(handle: Long) @@ -162,41 +230,65 @@ interface MtprotoNative { @Throws(MtprotoException::class) fun getChats(handle: Long): List - fun getWallpapers(handle: Long, hash: Long): uniffi.monogram_mtproto.WallpaperCatalogDto = - error("Wallpaper catalog unavailable") + fun getWallpapers( + handle: Long, + hash: Long, + ): uniffi.monogram_mtproto.WallpaperCatalogDto = error("Wallpaper catalog unavailable") - fun downloadWallpaper(handle: Long, id: Long, accessHash: Long, destPath: String): String = - error("Wallpaper download unavailable") + fun downloadWallpaper( + handle: Long, + id: Long, + accessHash: Long, + destPath: String, + ): String = error("Wallpaper download unavailable") @Throws(MtprotoException::class) - fun getWebPage(handle: Long, url: String, hash: Int): InstantViewDto = - throw MtprotoException.Message("instant view requires native rebuild") + fun getWebPage( + handle: Long, + url: String, + hash: Int, + ): InstantViewDto = throw MtprotoException.Message("instant view requires native rebuild") @Throws(MtprotoException::class) - fun getWebPagePreview(handle: Long, message: String): InstantViewDto = - getWebPage(handle, message, 0) + fun getWebPagePreview( + handle: Long, + message: String, + ): InstantViewDto = getWebPage(handle, message, 0) @Throws(MtprotoException::class) fun getFolders(handle: Long): List @Throws(MtprotoException::class) - fun updateFolder(handle: Long, folder: FolderDto): Unit = - throw MtprotoException.Message("update folder requires native rebuild") + fun updateFolder( + handle: Long, + folder: FolderDto, + ): Unit = throw MtprotoException.Message("update folder requires native rebuild") @Throws(MtprotoException::class) - fun deleteFolder(handle: Long, id: Int): Unit = - throw MtprotoException.Message("delete folder requires native rebuild") + fun deleteFolder( + handle: Long, + id: Int, + ): Unit = throw MtprotoException.Message("delete folder requires native rebuild") @Throws(MtprotoException::class) - fun updateFolderOrder(handle: Long, order: List): Unit = - throw MtprotoException.Message("folder order requires native rebuild") + fun updateFolderOrder( + handle: Long, + order: List, + ): Unit = throw MtprotoException.Message("folder order requires native rebuild") @Throws(MtprotoException::class) - fun getHistory(handle: Long, chatId: Long, limit: Int): List + fun getHistory( + handle: Long, + chatId: Long, + limit: Int, + ): List @Throws(MtprotoException::class) - fun getMessage(handle: Long, chatId: Long, messageId: Int): MessageDto? = - throw MtprotoException.Message("message lookup requires native rebuild") + fun getMessage( + handle: Long, + chatId: Long, + messageId: Int, + ): MessageDto? = throw MtprotoException.Message("message lookup requires native rebuild") @Throws(MtprotoException::class) fun getHistoryPage( @@ -216,8 +308,7 @@ interface MtprotoNative { limit: Int, offsetId: Int, addOffset: Int, - ): List = - throw MtprotoException.Message("replies require native rebuild") + ): List = throw MtprotoException.Message("replies require native rebuild") @Throws(MtprotoException::class) fun getForumTopics( @@ -227,16 +318,14 @@ interface MtprotoNative { offsetId: Int, offsetTopic: Int, limit: Int, - ): ForumTopicsPageDto = - throw MtprotoException.Message("forum topics require native rebuild") + ): ForumTopicsPageDto = throw MtprotoException.Message("forum topics require native rebuild") @Throws(MtprotoException::class) fun getForumTopicsById( handle: Long, chatId: Long, topicIds: List, - ): ForumTopicsPageDto = - throw MtprotoException.Message("forum topics require native rebuild") + ): ForumTopicsPageDto = throw MtprotoException.Message("forum topics require native rebuild") @Throws(MtprotoException::class) fun editForumTopicHidden( @@ -244,9 +333,7 @@ interface MtprotoNative { chatId: Long, topicId: Int, hidden: Boolean, - ) { - throw MtprotoException.Message("edit forum topic requires native rebuild") - } + ): Unit = throw MtprotoException.Message("edit forum topic requires native rebuild") @Throws(MtprotoException::class) fun loadMoreChats( @@ -258,7 +345,12 @@ interface MtprotoNative { ): List @Throws(MtprotoException::class) - fun searchMessages(handle: Long, chatId: Long, query: String, limit: Int): List + fun searchMessages( + handle: Long, + chatId: Long, + query: String, + limit: Int, + ): List @Throws(MtprotoException::class) fun searchMessagesFiltered( @@ -283,8 +375,11 @@ interface MtprotoNative { ): MessagesPageDto = throw MtprotoException.Message("search page requires native rebuild") @Throws(MtprotoException::class) - fun contactsSearch(handle: Long, query: String, limit: Int): ContactsSearchDto = - throw MtprotoException.Message("contacts search requires native rebuild") + fun contactsSearch( + handle: Long, + query: String, + limit: Int, + ): ContactsSearchDto = throw MtprotoException.Message("contacts search requires native rebuild") @Throws(MtprotoException::class) fun searchGlobal( @@ -299,21 +394,40 @@ interface MtprotoNative { throw MtprotoException.Message("global search requires native rebuild") @Throws(MtprotoException::class) - fun getPinnedMessages(handle: Long, chatId: Long, limit: Int): List + fun getPinnedMessages( + handle: Long, + chatId: Long, + limit: Int, + ): List @Throws(MtprotoException::class) fun getMessageReadParticipants( handle: Long, chatId: Long, msgId: Int, - ): ReadParticipantsDto = throw MtprotoException.Message("read participants require native rebuild") + ): ReadParticipantsDto = + throw MtprotoException.Message("read participants require native rebuild") @Throws(MtprotoException::class) - fun getMessageReactionsPage(handle: Long, chatId: Long, messageId: Int, offset: String?): uniffi.monogram_mtproto.ReactionPeersDto = - if (offset == null) getMessageReactionsList(handle, chatId, messageId) else throw MtprotoException.Message("reaction pages require native rebuild") + fun getMessageReactionsPage( + handle: Long, + chatId: Long, + messageId: Int, + offset: String?, + ): uniffi.monogram_mtproto.ReactionPeersDto = if (offset == null) { + getMessageReactionsList(handle, chatId, messageId) + } else { + throw MtprotoException.Message("reaction pages require native rebuild") + } - fun getPollVotesPage(handle: Long, chatId: Long, messageId: Int, offset: String?): uniffi.monogram_mtproto.PollVotersDto = - if (offset == null) getPollVotes(handle, chatId, messageId) else throw MtprotoException.Message("poll pages require native rebuild") + fun getPollVotesPage( + handle: Long, + chatId: Long, + messageId: Int, + offset: String?, + ): uniffi.monogram_mtproto.PollVotersDto = if (offset == null) getPollVotes( + handle, chatId, messageId + ) else throw MtprotoException.Message("poll pages require native rebuild") fun getMessageReactionsList( handle: Long, @@ -336,64 +450,129 @@ interface MtprotoNative { ): OutboxReadDto = throw MtprotoException.Message("outbox read date requires native rebuild") @Throws(MtprotoException::class) - fun getReadReceiptConfig(handle: Long): ReadReceiptConfigDto = - ReadReceiptConfigDto( - chatReadMarkSizeThreshold = 100, - chatReadMarkExpirePeriod = 604800, - pmReadDateExpirePeriod = 604800, - fromServer = false, - ) + fun getReadReceiptConfig(handle: Long): ReadReceiptConfigDto = ReadReceiptConfigDto( + chatReadMarkSizeThreshold = 100, + chatReadMarkExpirePeriod = 604800, + pmReadDateExpirePeriod = 604800, + fromServer = false, + ) - fun importContacts(handle: Long, contacts: List): uniffi.monogram_mtproto.ContactImportResult = + fun importContacts( + handle: Long, + contacts: List, + ): uniffi.monogram_mtproto.ContactImportResult = throw MtprotoException.Message("contact import unavailable") - fun sendLocationWithOptions(handle:Long,chatId:Long,spec:uniffi.monogram_mtproto.LocationSendSpec,replyToMsgId:Int,topMsgId:Int,randomId:Long,options:uniffi.monogram_mtproto.SendOptions):uniffi.monogram_mtproto.OutgoingMessageResult = + fun sendLocationWithOptions( + handle: Long, + chatId: Long, + spec: uniffi.monogram_mtproto.LocationSendSpec, + replyToMsgId: Int, + topMsgId: Int, + randomId: Long, + options: uniffi.monogram_mtproto.SendOptions, + ): uniffi.monogram_mtproto.OutgoingMessageResult = throw MtprotoException.Message("location send unavailable") - fun sendContactWithOptions(handle: Long, chatId: Long, spec: uniffi.monogram_mtproto.ContactSendSpec, replyToMsgId: Int, topMsgId: Int, - randomId: Long, options: uniffi.monogram_mtproto.SendOptions): uniffi.monogram_mtproto.OutgoingMessageResult = + + fun sendContactWithOptions( + handle: Long, + chatId: Long, + spec: uniffi.monogram_mtproto.ContactSendSpec, + replyToMsgId: Int, + topMsgId: Int, + randomId: Long, + options: uniffi.monogram_mtproto.SendOptions, + ): uniffi.monogram_mtproto.OutgoingMessageResult = throw MtprotoException.Message("contact send unavailable") - fun sendChecklistWithOptions(handle: Long, chatId: Long, spec: uniffi.monogram_mtproto.ChecklistSendSpec, replyToMsgId: Int, topMsgId: Int, - randomId: Long, options: uniffi.monogram_mtproto.SendOptions): uniffi.monogram_mtproto.OutgoingMessageResult = + fun sendChecklistWithOptions( + handle: Long, + chatId: Long, + spec: uniffi.monogram_mtproto.ChecklistSendSpec, + replyToMsgId: Int, + topMsgId: Int, + randomId: Long, + options: uniffi.monogram_mtproto.SendOptions, + ): uniffi.monogram_mtproto.OutgoingMessageResult = throw MtprotoException.Message("checklist send unavailable") - fun sendPollWithOptions(handle: Long, chatId: Long, spec: uniffi.monogram_mtproto.PollSendSpec, replyToMsgId: Int, topMsgId: Int, - randomId: Long, options: uniffi.monogram_mtproto.SendOptions): uniffi.monogram_mtproto.OutgoingMessageResult = + fun sendPollWithOptions( + handle: Long, + chatId: Long, + spec: uniffi.monogram_mtproto.PollSendSpec, + replyToMsgId: Int, + topMsgId: Int, + randomId: Long, + options: uniffi.monogram_mtproto.SendOptions, + ): uniffi.monogram_mtproto.OutgoingMessageResult = throw MtprotoException.Message("poll send unavailable") - fun sendDiceWithOptions(handle: Long, chatId: Long, emoticon: String, replyToMsgId: Int, topMsgId: Int, - randomId: Long, options: uniffi.monogram_mtproto.SendOptions): uniffi.monogram_mtproto.OutgoingMessageResult = + fun sendDiceWithOptions( + handle: Long, + chatId: Long, + emoticon: String, + replyToMsgId: Int, + topMsgId: Int, + randomId: Long, + options: uniffi.monogram_mtproto.SendOptions, + ): uniffi.monogram_mtproto.OutgoingMessageResult = throw MtprotoException.Message("dice send requires native rebuild") fun sendTextMessageWithOptions( - handle: Long, chatId: Long, text: String, replyToMsgId: Int, - entitiesJson: String?, topMsgId: Int, webpageUrl: String?, randomId: Long, + handle: Long, + chatId: Long, + text: String, + replyToMsgId: Int, + entitiesJson: String?, + topMsgId: Int, + webpageUrl: String?, + randomId: Long, options: uniffi.monogram_mtproto.SendOptions, ): uniffi.monogram_mtproto.OutgoingMessageResult = throw MtprotoException.Message("send options require native rebuild") - fun getScheduledHistory(handle: Long, chatId: Long): List = + fun getScheduledHistory( + handle: Long, + chatId: Long, + ): List = throw MtprotoException.Message("scheduled messages require native rebuild") - fun getScheduledMessages(handle: Long, chatId: Long, ids: List): List = + fun getScheduledMessages( + handle: Long, + chatId: Long, + ids: List, + ): List = throw MtprotoException.Message("scheduled messages require native rebuild") - fun sendScheduledMessages(handle: Long, chatId: Long, ids: List): Unit = - throw MtprotoException.Message("scheduled messages require native rebuild") + fun sendScheduledMessages( + handle: Long, + chatId: Long, + ids: List, + ): Unit = throw MtprotoException.Message("scheduled messages require native rebuild") - fun deleteScheduledMessages(handle: Long, chatId: Long, ids: List): Unit = - throw MtprotoException.Message("scheduled messages require native rebuild") + fun deleteScheduledMessages( + handle: Long, + chatId: Long, + ids: List, + ): Unit = throw MtprotoException.Message("scheduled messages require native rebuild") fun editScheduledMessage( - handle: Long, chatId: Long, messageId: Int, scheduleDate: Int, - text: String?, entitiesJson: String?, noWebpage: Boolean, + handle: Long, + chatId: Long, + messageId: Int, + scheduleDate: Int, + text: String?, + entitiesJson: String?, + noWebpage: Boolean, ): Unit = throw MtprotoException.Message("scheduled messages require native rebuild") fun getCloudDrafts(handle: Long): List = throw MtprotoException.Message("cloud drafts require native rebuild") - fun saveCloudDraft(handle: Long, draft: uniffi.monogram_mtproto.CloudDraft): Unit = - throw MtprotoException.Message("cloud drafts require native rebuild") + fun saveCloudDraft( + handle: Long, + draft: uniffi.monogram_mtproto.CloudDraft, + ): Unit = throw MtprotoException.Message("cloud drafts require native rebuild") @Throws(MtprotoException::class) fun sendTextMessage( @@ -418,8 +597,13 @@ interface MtprotoNative { entitiesJson: String? = null, ): MessageDto - fun replaceUploadedMedia(handle:Long,chatId:Long,messageId:Int,expectedKey:String,item:UploadItemDto):MessageDto = - throw MtprotoException.Message("media replacement requires native rebuild") + fun replaceUploadedMedia( + handle: Long, + chatId: Long, + messageId: Int, + expectedKey: String, + item: UploadItemDto, + ): MessageDto = throw MtprotoException.Message("media replacement requires native rebuild") @Throws(MtprotoException::class) fun sendUploadedMedia( @@ -441,14 +625,25 @@ interface MtprotoNative { ): List = throw MtprotoException.Message("albums require native rebuild") fun sendUploadedMediaWithOptions( - handle: Long, chatId: Long, item: UploadItemDto, replyToMsgId: Int, topMsgId: Int, - entitiesJson: String?, options: uniffi.monogram_mtproto.SendOptions, - ): uniffi.monogram_mtproto.OutgoingMessageResult = throw MtprotoException.Message("upload options require native rebuild") + handle: Long, + chatId: Long, + item: UploadItemDto, + replyToMsgId: Int, + topMsgId: Int, + entitiesJson: String?, + options: uniffi.monogram_mtproto.SendOptions, + ): uniffi.monogram_mtproto.OutgoingMessageResult = + throw MtprotoException.Message("upload options require native rebuild") fun sendUploadedAlbumWithOptions( - handle: Long, chatId: Long, items: List, replyToMsgId: Int, topMsgId: Int, + handle: Long, + chatId: Long, + items: List, + replyToMsgId: Int, + topMsgId: Int, options: uniffi.monogram_mtproto.SendOptions, - ): List = throw MtprotoException.Message("album options require native rebuild") + ): List = + throw MtprotoException.Message("album options require native rebuild") @Throws(MtprotoException::class) fun editTextMessage( @@ -460,7 +655,12 @@ interface MtprotoNative { ): MessageDto @Throws(MtprotoException::class) - fun deleteMessage(handle: Long, chatId: Long, messageId: Int, revoke: Boolean) + fun deleteMessage( + handle: Long, + chatId: Long, + messageId: Int, + revoke: Boolean, + ) @Throws(MtprotoException::class) fun forwardMessages( @@ -472,16 +672,27 @@ interface MtprotoNative { ): List @Throws(MtprotoException::class) - fun readHistory(handle: Long, chatId: Long, maxId: Int) + fun readHistory( + handle: Long, + chatId: Long, + maxId: Int, + ) /** Acknowledges the supplied mention/reaction message ids without clearing the dialog. */ @Throws(MtprotoException::class) - fun readMessageContents(handle: Long, chatId: Long, messageIds: List): Unit = - throw MtprotoException.Message("message contents require native rebuild") + fun readMessageContents( + handle: Long, + chatId: Long, + messageIds: List, + ): Unit = throw MtprotoException.Message("message contents require native rebuild") /** `messages.markDialogUnread`: toggles the manual unread mark on a dialog. */ @Throws(MtprotoException::class) - fun markDialogUnread(handle: Long, chatId: Long, unread: Boolean) + fun markDialogUnread( + handle: Long, + chatId: Long, + unread: Boolean, + ) /** `messages.getUnreadMentions` */ @Throws(MtprotoException::class) @@ -492,13 +703,15 @@ interface MtprotoNative { addOffset: Int, limit: Int, topMsgId: Int, - ): List = - throw MtprotoException.Message("unread mentions require native rebuild") + ): List = throw MtprotoException.Message("unread mentions require native rebuild") /** `messages.readMentions` */ @Throws(MtprotoException::class) - fun readMentions(handle: Long, chatId: Long, topMsgId: Int): Unit = - throw MtprotoException.Message("unread mentions require native rebuild") + fun readMentions( + handle: Long, + chatId: Long, + topMsgId: Int, + ): Unit = throw MtprotoException.Message("unread mentions require native rebuild") /** `messages.getUnreadReactions` */ @Throws(MtprotoException::class) @@ -509,23 +722,36 @@ interface MtprotoNative { addOffset: Int, limit: Int, topMsgId: Int, - ): List = - throw MtprotoException.Message("unread reactions require native rebuild") + ): List = throw MtprotoException.Message("unread reactions require native rebuild") /** `messages.readReactions` */ @Throws(MtprotoException::class) - fun readReactions(handle: Long, chatId: Long, topMsgId: Int): Unit = - throw MtprotoException.Message("unread reactions require native rebuild") + fun readReactions( + handle: Long, + chatId: Long, + topMsgId: Int, + ): Unit = throw MtprotoException.Message("unread reactions require native rebuild") @Throws(MtprotoException::class) - fun readDiscussion(handle: Long, chatId: Long, msgId: Int, readMaxId: Int): Unit = - throw MtprotoException.Message("forum topics require native rebuild") + fun readDiscussion( + handle: Long, + chatId: Long, + msgId: Int, + readMaxId: Int, + ): Unit = throw MtprotoException.Message("forum topics require native rebuild") @Throws(MtprotoException::class) - fun setTyping(handle: Long, chatId: Long, typing: Boolean) + fun setTyping( + handle: Long, + chatId: Long, + typing: Boolean, + ) @Throws(MtprotoException::class) - fun updateStatus(handle: Long, offline: Boolean) + fun updateStatus( + handle: Long, + offline: Boolean, + ) @Throws(MtprotoException::class) fun registerDevice( @@ -547,7 +773,11 @@ interface MtprotoNative { ) @Throws(MtprotoException::class) - fun getNotifySettings(handle: Long, peerKind: String, chatId: Long): NotifySettingsDto + fun getNotifySettings( + handle: Long, + peerKind: String, + chatId: Long, + ): NotifySettingsDto @Throws(MtprotoException::class) fun updateNotifySettings( @@ -566,30 +796,55 @@ interface MtprotoNative { /** `channels.joinChannel` or `messages.addChatUser`. `joined` is false when membership was not granted. */ @Throws(MtprotoException::class) - fun joinChat(handle: Long, chatId: Long): NativeJoinResult + fun joinChat( + handle: Long, + chatId: Long, + ): NativeJoinResult /** `contacts.unblock` for a user peer. */ @Throws(MtprotoException::class) - fun unblockUser(handle: Long, chatId: Long) + fun unblockUser( + handle: Long, + chatId: Long, + ) @Throws(MtprotoException::class) - fun setContactJoinedSilent(handle: Long, silent: Boolean) + fun setContactJoinedSilent( + handle: Long, + silent: Boolean, + ) @Throws(MtprotoException::class) - fun getNotifyExceptions(handle: Long, compareSound: Boolean): List + fun getNotifyExceptions( + handle: Long, + compareSound: Boolean, + ): List @Throws(MtprotoException::class) - fun decryptPushPayload(secret: ByteArray, payload: String): String + fun decryptPushPayload( + secret: ByteArray, + payload: String, + ): String @Throws(MtprotoException::class) - fun getProfile(handle: Long, peerId: Long): ProfileDto + fun getProfile( + handle: Long, + peerId: Long, + ): ProfileDto @Throws(MtprotoException::class) - fun getGroupAdminTags(handle: Long, chatId: Long): String + fun getGroupAdminTags( + handle: Long, + chatId: Long, + ): String /** `messages.getSearchCounters` counts per filter, as compact JSON. */ @Throws(MtprotoException::class) - fun getSearchCounters(handle: Long, chatId: Long, filters: List): String + fun getSearchCounters( + handle: Long, + chatId: Long, + filters: List, + ): String /** Participant page with roles, as compact JSON. */ @Throws(MtprotoException::class) @@ -604,7 +859,12 @@ interface MtprotoNative { /** Groups and channels shared with a user, as compact JSON. */ @Throws(MtprotoException::class) - fun getCommonChats(handle: Long, userId: Long, maxId: Long, limit: Int): String + fun getCommonChats( + handle: Long, + userId: Long, + maxId: Long, + limit: Int, + ): String @Throws(MtprotoException::class) fun startUpdates(handle: Long) @@ -613,7 +873,11 @@ interface MtprotoNative { * Offscreen channels use `messages.getPeerDialogs`. The open chat stays on * channel difference through the native history cursor. */ - fun updateLazySyncConfig(handle: Long, lazy: Boolean, exceptions: List) = Unit + fun updateLazySyncConfig( + handle: Long, + lazy: Boolean, + exceptions: List, + ) = Unit @Throws(MtprotoException::class) fun clearActiveDialog(handle: Long) = Unit @@ -622,65 +886,120 @@ interface MtprotoNative { fun drainUpdates(handle: Long): List @Throws(MtprotoException::class) - fun drainUpdateBatches(handle: Long): UpdateDrainDto = - UpdateDrainDto( - batches = emptyList(), - liveEvents = drainUpdates(handle), - replayRequired = false - ) + fun drainUpdateBatches(handle: Long): UpdateDrainDto = UpdateDrainDto( + batches = emptyList(), + liveEvents = drainUpdates(handle), + replayRequired = false, + ) @Throws(MtprotoException::class) fun pendingUpdateBatches(handle: Long): List = emptyList() @Throws(MtprotoException::class) - fun acknowledgeUpdateBatch(handle: Long, accountGeneration: Long, batchId: Long) = Unit + fun acknowledgeUpdateBatch( + handle: Long, + accountGeneration: Long, + batchId: Long, + ) = Unit @Throws(MtprotoException::class) - fun downloadMessageMedia(handle: Long, chatId: Long, messageId: Int, destPath: String): String - fun downloadMessageMediaChunk(handle: Long, chatId: Long, messageId: Int, destPath: String, offset: Long): String = - throw MtprotoException.Message("media streaming unavailable") + fun downloadMessageMediaChecked( + handle: Long, + chatId: Long, + messageId: Int, + destPath: String, + expectedCacheKey: String, + kind: String, + offset: Long?, + ): String = throw MtprotoException.Message("checked media download unavailable") @Throws(MtprotoException::class) - fun downloadMessageThumb(handle: Long, chatId: Long, messageId: Int, destPath: String): String = - downloadMessageMedia(handle, chatId, messageId, destPath) + fun downloadMessageMedia( + handle: Long, + chatId: Long, + messageId: Int, + destPath: String, + ): String - fun peekMessageInlineThumb(handle: Long, chatId: Long, messageId: Int): ByteArray? = null + fun downloadMessageMediaChunk( + handle: Long, + chatId: Long, + messageId: Int, + destPath: String, + offset: Long, + ): String = throw MtprotoException.Message("media streaming unavailable") - fun peekPeerInlineThumb(handle: Long, peerId: Long): ByteArray? = null + @Throws(MtprotoException::class) + fun downloadMessageThumb( + handle: Long, + chatId: Long, + messageId: Int, + destPath: String, + ): String = downloadMessageMedia(handle, chatId, messageId, destPath) + + fun peekMessageInlineThumb( + handle: Long, + chatId: Long, + messageId: Int, + ): ByteArray? = null + + fun peekPeerInlineThumb( + handle: Long, + peerId: Long, + ): ByteArray? = null @Throws(MtprotoException::class) - fun downloadMessageDisplay(handle: Long, chatId: Long, messageId: Int, destPath: String): String = - downloadMessageMedia(handle, chatId, messageId, destPath) + fun downloadMessageDisplay( + handle: Long, + chatId: Long, + messageId: Int, + destPath: String, + ): String = downloadMessageMedia(handle, chatId, messageId, destPath) @Throws(MtprotoException::class) - fun downloadCustomEmoji(handle: Long, documentId: Long, destPath: String): String { - throw MtprotoException.Message("custom emoji requires native rebuild") - } + fun downloadCustomEmoji( + handle: Long, + documentId: Long, + destPath: String, + ): String = throw MtprotoException.Message("custom emoji requires native rebuild") @Throws(MtprotoException::class) - fun getStickerPack(handle: Long, documentId: Long): StickerPackDto { - throw MtprotoException.Message("sticker pack requires native rebuild") - } + fun getStickerPack( + handle: Long, + documentId: Long, + ): StickerPackDto = throw MtprotoException.Message("sticker pack requires native rebuild") @Throws(MtprotoException::class) - fun getStickerSet(handle: Long, setId: Long, accessHash: Long): StickerPackDto = - throw MtprotoException.Message("sticker set requires native rebuild") + fun getStickerSet( + handle: Long, + setId: Long, + accessHash: Long, + ): StickerPackDto = throw MtprotoException.Message("sticker set requires native rebuild") @Throws(MtprotoException::class) - fun getAllStickers(handle: Long, hash: Long): StickerCatalogDto = - throw MtprotoException.Message("sticker catalog requires native rebuild") + fun getAllStickers( + handle: Long, + hash: Long, + ): StickerCatalogDto = throw MtprotoException.Message("sticker catalog requires native rebuild") @Throws(MtprotoException::class) - fun getEmojiStickers(handle: Long, hash: Long): StickerCatalogDto = - throw MtprotoException.Message("emoji stickers require native rebuild") + fun getEmojiStickers( + handle: Long, + hash: Long, + ): StickerCatalogDto = throw MtprotoException.Message("emoji stickers require native rebuild") @Throws(MtprotoException::class) - fun getStickers(handle: Long, emoticon: String, hash: Long): StickerListDto = - throw MtprotoException.Message("sticker suggestions require native rebuild") + fun getStickers( + handle: Long, + emoticon: String, + hash: Long, + ): StickerListDto = throw MtprotoException.Message("sticker suggestions require native rebuild") @Throws(MtprotoException::class) - fun resolveUsername(handle: Long, username: String): ResolvedPeerDto = - throw MtprotoException.Message("resolve username requires native rebuild") + fun resolveUsername( + handle: Long, + username: String, + ): ResolvedPeerDto = throw MtprotoException.Message("resolve username requires native rebuild") @Throws(MtprotoException::class) fun getInlineBotResults( @@ -689,8 +1008,7 @@ interface MtprotoNative { botId: Long, query: String, offset: String, - ): InlineBotResultsDto = - throw MtprotoException.Message("inline bots require native rebuild") + ): InlineBotResultsDto = throw MtprotoException.Message("inline bots require native rebuild") @Throws(MtprotoException::class) fun sendInlineBotResult( @@ -700,8 +1018,7 @@ interface MtprotoNative { resultId: String, replyToMsgId: Int, topMsgId: Int, - ): MessageDto = - throw MtprotoException.Message("inline send requires native rebuild") + ): MessageDto = throw MtprotoException.Message("inline send requires native rebuild") @Throws(MtprotoException::class) fun getSavedGifs(handle: Long): List = @@ -713,8 +1030,7 @@ interface MtprotoNative { chatId: Long, messageId: Int, dataHex: String, - ): BotCallbackAnswerDto = - throw MtprotoException.Message("bot callback requires native rebuild") + ): BotCallbackAnswerDto = throw MtprotoException.Message("bot callback requires native rebuild") @Throws(MtprotoException::class) fun toggleTodoCompleted( @@ -723,12 +1039,19 @@ interface MtprotoNative { messageId: Int, completed: List, incompleted: List, - ): Unit = - throw MtprotoException.Message("todo toggle requires native rebuild") + ): Unit = throw MtprotoException.Message("todo toggle requires native rebuild") @Throws(MtprotoException::class) - fun editLiveLocation(handle:Long,chatId:Long,messageId:Int,latitude:Double?,longitude:Double?,heading:Int?,stopped:Boolean):Unit = - throw UnsupportedOperationException("editLiveLocation") + fun editLiveLocation( + handle: Long, + chatId: Long, + messageId: Int, + latitude: Double?, + longitude: Double?, + heading: Int?, + stopped: Boolean, + ): Unit = throw UnsupportedOperationException("editLiveLocation") + fun sendLocation( handle: Long, chatId: Long, @@ -737,27 +1060,42 @@ interface MtprotoNative { livePeriodSeconds: Int, heading: Int, replyToMsgId: Int, - ): Unit = - throw MtprotoException.Message("location send requires native rebuild") + ): Unit = throw MtprotoException.Message("location send requires native rebuild") @Throws(MtprotoException::class) - fun removeChecklistItem(handle:Long,chatId:Long,messageId:Int,edit:uniffi.monogram_mtproto.ChecklistTextEditDto):Unit = - throw UnsupportedOperationException("removeChecklistItem") - fun editChecklistText(handle: Long, chatId: Long, messageId: Int, edit: uniffi.monogram_mtproto.ChecklistTextEditDto): Unit = - throw UnsupportedOperationException("editChecklistText") - fun editChecklistTitle(handle: Long, chatId: Long, messageId: Int, title: String): Unit = - throw MtprotoException.Message("checklist editing unavailable") + fun removeChecklistItem( + handle: Long, + chatId: Long, + messageId: Int, + edit: uniffi.monogram_mtproto.ChecklistTextEditDto, + ): Unit = throw UnsupportedOperationException("removeChecklistItem") + + fun editChecklistText( + handle: Long, + chatId: Long, + messageId: Int, + edit: uniffi.monogram_mtproto.ChecklistTextEditDto, + ): Unit = throw UnsupportedOperationException("editChecklistText") - fun closePoll(handle: Long, chatId: Long, messageId: Int): Unit = - throw MtprotoException.Message("poll close requires native rebuild") + fun editChecklistTitle( + handle: Long, + chatId: Long, + messageId: Int, + title: String, + ): Unit = throw MtprotoException.Message("checklist editing unavailable") + + fun closePoll( + handle: Long, + chatId: Long, + messageId: Int, + ): Unit = throw MtprotoException.Message("poll close requires native rebuild") fun sendPollVote( handle: Long, chatId: Long, messageId: Int, options: List, - ): Unit = - throw MtprotoException.Message("poll vote requires native rebuild") + ): Unit = throw MtprotoException.Message("poll vote requires native rebuild") @Throws(MtprotoException::class) fun appendTodoItems( @@ -766,8 +1104,7 @@ interface MtprotoNative { messageId: Int, firstId: Int, titles: List, - ): Unit = - throw MtprotoException.Message("checklist append requires native rebuild") + ): Unit = throw MtprotoException.Message("checklist append requires native rebuild") @Throws(MtprotoException::class) fun sendSavedGif( @@ -776,8 +1113,7 @@ interface MtprotoNative { documentId: Long, replyToMsgId: Int, topMsgId: Int = 0, - ): MessageDto = - throw MtprotoException.Message("saved gifs requires native rebuild") + ): MessageDto = throw MtprotoException.Message("saved gifs requires native rebuild") @Throws(MtprotoException::class) fun sendReaction( @@ -786,13 +1122,14 @@ interface MtprotoNative { messageId: Int, emoticon: String, documentId: Long, - ) { - throw MtprotoException.Message("reactions require native rebuild") - } + ): Unit = throw MtprotoException.Message("reactions require native rebuild") @Throws(MtprotoException::class) - fun getDiscussionMessage(handle: Long, chatId: Long, messageId: Int): DiscussionDto = - throw MtprotoException.Message("discussion requires native rebuild") + fun getDiscussionMessage( + handle: Long, + chatId: Long, + messageId: Int, + ): DiscussionDto = throw MtprotoException.Message("discussion requires native rebuild") @Throws(MtprotoException::class) fun getRecentReactions(handle: Long): List = @@ -802,11 +1139,15 @@ interface MtprotoNative { fun animatedEmojiMax(handle: Long): Int = 0 @Throws(MtprotoException::class) - fun customEmojiIsFree(handle: Long, documentId: Long): Boolean = false + fun customEmojiIsFree( + handle: Long, + documentId: Long, + ): Boolean = false @Throws(MtprotoException::class) fun getUpdatesState(handle: Long): UpdatesStateDto object Stub : MtprotoNative by MtprotoNativeStub + object UniFfi : MtprotoNative by MtprotoNativeUniFfi } diff --git a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeLoader.kt b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeLoader.kt index c3aab0973..e5d1751e1 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeLoader.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeLoader.kt @@ -2,6 +2,8 @@ package org.monogram.mtproto import android.content.Context import android.util.Log +import androidx.annotation.RestrictTo +import java.io.File object MtprotoNativeLoader { @Volatile @@ -38,5 +40,14 @@ object MtprotoNativeLoader { } } + /** Call only after closing the client that owns the source session. */ + fun promoteSession(source: File, destination: File) = + SessionKeyStore.promote(source, destination) + + /** Only a journaled destination owner may call this with both clients closed. */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + fun recoverSessionPromotion(source: File, destination: File) = + SessionKeyStore.recoverPromotion(source, destination) + fun isUsingStub(): Boolean = cached === MtprotoNative.Stub } diff --git a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt index 06a89aef1..eb40661eb 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt @@ -114,6 +114,7 @@ import uniffi.monogram_mtproto.readMentions as nativeReadMentions import uniffi.monogram_mtproto.readMessageContents as nativeReadMessageContents import uniffi.monogram_mtproto.readReactions as nativeReadReactions import uniffi.monogram_mtproto.registerDevice as nativeRegisterDevice +import uniffi.monogram_mtproto.replaceUploadedMedia as nativeReplaceUploadedMedia import uniffi.monogram_mtproto.resendAuthCode as nativeResendAuthCode import uniffi.monogram_mtproto.resetNotifySettings as nativeResetNotifySettings import uniffi.monogram_mtproto.resolveUsername as nativeResolveUsername @@ -132,9 +133,8 @@ import uniffi.monogram_mtproto.sendSavedGif as nativeSendSavedGif import uniffi.monogram_mtproto.sendTextMessage as nativeSendTextMessage import uniffi.monogram_mtproto.sendUploadedAlbum as nativeSendUploadedAlbum import uniffi.monogram_mtproto.sendUploadedAlbumWithOptions as nativeSendUploadedAlbumWithOptions -import uniffi.monogram_mtproto.sendUploadedMediaWithOptions as nativeSendUploadedMediaWithOptions -import uniffi.monogram_mtproto.replaceUploadedMedia as nativeReplaceUploadedMedia import uniffi.monogram_mtproto.sendUploadedMedia as nativeSendUploadedMedia +import uniffi.monogram_mtproto.sendUploadedMediaWithOptions as nativeSendUploadedMediaWithOptions import uniffi.monogram_mtproto.setClientTestDc as nativeSetClientTestDc import uniffi.monogram_mtproto.setContactJoinedSilent as nativeSetContactJoinedSilent import uniffi.monogram_mtproto.setInitConnectionInfo as nativeSetInitConnectionInfo @@ -154,62 +154,136 @@ import uniffi.monogram_mtproto.updateStatus as nativeUpdateStatus object MtprotoNativeUniFfi : MtprotoNative { override fun getDeviceSessions(handle: Long) = nativeGetDeviceSessions(handle.toULong()) - override fun acceptLoginToken(handle: Long, token: ByteArray) = nativeAcceptLoginToken(handle.toULong(), token) - override fun revokeDeviceSession(handle: Long, hash: Long) = nativeRevokeDeviceSession(handle.toULong(), hash) + + override fun acceptLoginToken( + handle: Long, + token: ByteArray, + ) = nativeAcceptLoginToken(handle.toULong(), token) + + override fun revokeDeviceSession( + handle: Long, + hash: Long, + ) = nativeRevokeDeviceSession(handle.toULong(), hash) + override fun passkeysEnabled(handle: Long) = uniffi.monogram_mtproto.passkeysEnabled(handle.toULong()) + override fun initPasskeyLogin(handle: Long) = uniffi.monogram_mtproto.initPasskeyLogin(handle.toULong()) - override fun finishPasskeyLogin(handle: Long, credential: uniffi.monogram_mtproto.PasskeyCredentialDto, dcId: Int, authKeyId: Long) = uniffi.monogram_mtproto.finishPasskeyLogin(handle.toULong(), credential, dcId, authKeyId) + + override fun finishPasskeyLogin( + handle: Long, + credential: uniffi.monogram_mtproto.PasskeyCredentialDto, + dcId: Int, + authKeyId: Long, + ) = uniffi.monogram_mtproto.finishPasskeyLogin(handle.toULong(), credential, dcId, authKeyId) + override fun initPasskeyRegistration(handle: Long) = uniffi.monogram_mtproto.initPasskeyRegistration(handle.toULong()) - override fun registerPasskey(handle: Long, credential: uniffi.monogram_mtproto.PasskeyCredentialDto) = uniffi.monogram_mtproto.registerPasskey(handle.toULong(), credential) + + override fun registerPasskey( + handle: Long, + credential: uniffi.monogram_mtproto.PasskeyCredentialDto, + ) = uniffi.monogram_mtproto.registerPasskey(handle.toULong(), credential) + override fun getPasskeys(handle: Long) = uniffi.monogram_mtproto.getPasskeys(handle.toULong()) - override fun deletePasskey(handle: Long, id: String) = uniffi.monogram_mtproto.deletePasskey(handle.toULong(), id) + + override fun deletePasskey( + handle: Long, + id: String, + ) = uniffi.monogram_mtproto.deletePasskey(handle.toULong(), id) override fun exportLoginToken(handle: Long) = uniffi.monogram_mtproto.exportLoginToken(handle.toULong()) + override fun pollLoginToken(handle: Long) = uniffi.monogram_mtproto.pollLoginToken(handle.toULong()) - override fun downloadMessageMediaChunk(handle: Long, chatId: Long, messageId: Int, destPath: String, offset: Long): String = - uniffi.monogram_mtproto.downloadMessageMediaChunk(handle.toULong(), chatId, messageId, destPath, offset) + override fun downloadMessageMediaChecked( + handle: Long, + chatId: Long, + messageId: Int, + destPath: String, + expectedCacheKey: String, + kind: String, + offset: Long?, + ): String = + uniffi.monogram_mtproto.downloadMessageMediaChecked( + handle.toULong(), + chatId, + messageId, + destPath, + expectedCacheKey, + kind, + offset + ) + + override fun downloadMessageMediaChunk( + handle: Long, + chatId: Long, + messageId: Int, + destPath: String, + offset: Long, + ): String = uniffi.monogram_mtproto.downloadMessageMediaChunk( + handle.toULong(), + chatId, + messageId, + destPath, + offset + ) + override fun createRequestControl(): Long = uniffi.monogram_mtproto.createRequestControl().toLong() + override fun bindRequestControl(id: Long): Long = uniffi.monogram_mtproto.bindRequestControl(id.toULong()).toLong() + override fun cancelRequestControl(id: Long) = uniffi.monogram_mtproto.cancelRequestControl(id.toULong()) - override fun promoteRequestControl(id: Long, dispatchClass: Int) = - uniffi.monogram_mtproto.promoteRequestControl(id.toULong(), dispatchClass) + override fun promoteRequestControl( + id: Long, + dispatchClass: Int, + ) = uniffi.monogram_mtproto.promoteRequestControl(id.toULong(), dispatchClass) + override fun releaseRequestControl(id: Long) = uniffi.monogram_mtproto.releaseRequestControl(id.toULong()) - override fun setDispatchClass(classId: Int) = - uniffi.monogram_mtproto.setDispatchClass(classId) - override fun setDownloadConcurrency(lanes: Int, parts: Int) = - uniffi.monogram_mtproto.setDownloadConcurrency(lanes, parts) + override fun setDispatchClass(classId: Int) = uniffi.monogram_mtproto.setDispatchClass(classId) - override fun downloadConcurrency(): List = - uniffi.monogram_mtproto.downloadConcurrency() + override fun setDownloadConcurrency( + lanes: Int, + parts: Int, + ) = uniffi.monogram_mtproto.setDownloadConcurrency(lanes, parts) - override fun setFilePartKib(kib: Int) = - uniffi.monogram_mtproto.setFilePartKib(kib) + override fun downloadConcurrency(): List = uniffi.monogram_mtproto.downloadConcurrency() - override fun setDownloadChunkKib(kib: Int) = - uniffi.monogram_mtproto.setDownloadChunkKib(kib) + override fun setFilePartKib(kib: Int) = uniffi.monogram_mtproto.setFilePartKib(kib) - override fun downloadChunkKib(): Int = - uniffi.monogram_mtproto.downloadChunkKib() + override fun setDownloadChunkKib(kib: Int) = uniffi.monogram_mtproto.setDownloadChunkKib(kib) + + override fun downloadChunkKib(): Int = uniffi.monogram_mtproto.downloadChunkKib() override fun setDownloadProgressListener(listener: ((String, Long, Long) -> Unit)?) { val cb = listener ?: return uniffi.monogram_mtproto.setDownloadProgressListener( object : uniffi.monogram_mtproto.DownloadProgressListener { - override fun onProgress(path: String, downloaded: Long, total: Long) { + override fun onProgress( + path: String, + downloaded: Long, + total: Long, + ) { cb(path, downloaded, total) } }, ) } + init { uniffiEnsureInitialized() } override fun setTransportMode(mode: String) = nativeSetTransportMode(mode) - override fun setProxy(kind: String, host: String, port: Int, username: String?, password: String?, secret: ByteArray) = nativeSetProxy(kind, host, port.toUShort(), username, password, secret) + + override fun setProxy( + kind: String, + host: String, + port: Int, + username: String?, + password: String?, + secret: ByteArray, + ) = nativeSetProxy(kind, host, port.toUShort(), username, password, secret) override fun pingProxy( kind: String, @@ -244,18 +318,21 @@ object MtprotoNativeUniFfi : MtprotoNative { ), ) - override fun perfSetEnabled(enabled: Boolean) = - uniffi.monogram_mtproto.perfSetEnabled(enabled) - - override fun perfSnapshot(reset: Boolean): String = - uniffi.monogram_mtproto.perfSnapshot(reset) - - override fun createClient(apiId: Int, apiHash: String, sessionPath: String): Long { - val key = try { - SessionKeyStore.loadOrCreate(sessionPath) - } catch (_: Exception) { - throw MtprotoException.Message("session key unavailable") - } + override fun perfSetEnabled(enabled: Boolean) = uniffi.monogram_mtproto.perfSetEnabled(enabled) + + override fun perfSnapshot(reset: Boolean): String = uniffi.monogram_mtproto.perfSnapshot(reset) + + override fun createClient( + apiId: Int, + apiHash: String, + sessionPath: String, + ): Long { + val key = + try { + SessionKeyStore.loadOrCreate(sessionPath) + } catch (_: Exception) { + throw MtprotoException.Message("session key unavailable") + } return try { nativeCreateEncryptedClient(apiId, apiHash, File(sessionPath).canonicalPath, key).toLong() } finally { @@ -263,15 +340,16 @@ object MtprotoNativeUniFfi : MtprotoNative { } } - override fun setTestDc(handle: Long, enabled: Boolean) { + override fun setTestDc( + handle: Long, + enabled: Boolean, + ) { nativeSetClientTestDc(handle.toULong(), enabled) } - override fun usesTestDc(handle: Long): Boolean = - nativeClientUsesTestDc(handle.toULong()) + override fun usesTestDc(handle: Long): Boolean = nativeClientUsesTestDc(handle.toULong()) - override fun isAuthorized(handle: Long): Boolean = - nativeIsAuthorized(handle.toULong()) + override fun isAuthorized(handle: Long): Boolean = nativeIsAuthorized(handle.toULong()) override fun connect(handle: Long) { nativeConnect(handle.toULong()) @@ -285,8 +363,10 @@ object MtprotoNativeUniFfi : MtprotoNative { override fun clientApiId(handle: Long): Int = nativeClientApiId(handle.toULong()) - override fun sendAuthCode(handle: Long, phone: String): AuthCodeSent = - nativeSendAuthCode(handle.toULong(), phone) + override fun sendAuthCode( + handle: Long, + phone: String, + ): AuthCodeSent = nativeSendAuthCode(handle.toULong(), phone) override fun resendAuthCode( handle: Long, @@ -301,8 +381,10 @@ object MtprotoNativeUniFfi : MtprotoNative { phoneCode: String, ): AuthSignedIn = nativeSignIn(handle.toULong(), phone, phoneCodeHash, phoneCode) - override fun checkPassword(handle: Long, password: String): AuthSignedIn = - nativeCheckPassword(handle.toULong(), password) + override fun checkPassword( + handle: Long, + password: String, + ): AuthSignedIn = nativeCheckPassword(handle.toULong(), password) override fun logout(handle: Long) { nativeLogout(handle.toULong()) @@ -310,34 +392,57 @@ object MtprotoNativeUniFfi : MtprotoNative { override fun getChats(handle: Long): List = nativeGetChats(handle.toULong()) - override fun getWallpapers(handle: Long, hash: Long) = - uniffi.monogram_mtproto.getWallpapers(handle.toULong(), hash) + override fun getWallpapers( + handle: Long, + hash: Long, + ) = uniffi.monogram_mtproto.getWallpapers(handle.toULong(), hash) - override fun downloadWallpaper(handle: Long, id: Long, accessHash: Long, destPath: String) = - uniffi.monogram_mtproto.downloadWallpaper(handle.toULong(), id, accessHash, destPath) + override fun downloadWallpaper( + handle: Long, + id: Long, + accessHash: Long, + destPath: String, + ) = uniffi.monogram_mtproto.downloadWallpaper(handle.toULong(), id, accessHash, destPath) - override fun getWebPage(handle: Long, url: String, hash: Int): InstantViewDto = - nativeGetWebPage(handle.toULong(), url, hash) + override fun getWebPage( + handle: Long, + url: String, + hash: Int, + ): InstantViewDto = nativeGetWebPage(handle.toULong(), url, hash) - override fun getWebPagePreview(handle: Long, message: String): InstantViewDto = - nativeGetWebPagePreview(handle.toULong(), message) + override fun getWebPagePreview( + handle: Long, + message: String, + ): InstantViewDto = nativeGetWebPagePreview(handle.toULong(), message) override fun getFolders(handle: Long): List = nativeGetFolders(handle.toULong()) - override fun updateFolder(handle: Long, folder: FolderDto) = - nativeUpdateFolder(handle.toULong(), folder) + override fun updateFolder( + handle: Long, + folder: FolderDto, + ) = nativeUpdateFolder(handle.toULong(), folder) - override fun deleteFolder(handle: Long, id: Int) = - nativeDeleteFolder(handle.toULong(), id) + override fun deleteFolder( + handle: Long, + id: Int, + ) = nativeDeleteFolder(handle.toULong(), id) - override fun updateFolderOrder(handle: Long, order: List) = - nativeUpdateFolderOrder(handle.toULong(), order) + override fun updateFolderOrder( + handle: Long, + order: List, + ) = nativeUpdateFolderOrder(handle.toULong(), order) - override fun getHistory(handle: Long, chatId: Long, limit: Int): List = - nativeGetHistory(handle.toULong(), chatId, limit) + override fun getHistory( + handle: Long, + chatId: Long, + limit: Int, + ): List = nativeGetHistory(handle.toULong(), chatId, limit) - override fun getMessage(handle: Long, chatId: Long, messageId: Int): MessageDto? = - uniffi.monogram_mtproto.getMessage(handle.toULong(), chatId, messageId) + override fun getMessage( + handle: Long, + chatId: Long, + messageId: Int, + ): MessageDto? = uniffi.monogram_mtproto.getMessage(handle.toULong(), chatId, messageId) override fun getHistoryPage( handle: Long, @@ -373,8 +478,7 @@ object MtprotoNativeUniFfi : MtprotoNative { handle: Long, chatId: Long, topicIds: List, - ): ForumTopicsPageDto = - nativeGetForumTopicsById(handle.toULong(), chatId, topicIds) + ): ForumTopicsPageDto = nativeGetForumTopicsById(handle.toULong(), chatId, topicIds) override fun editForumTopicHidden( handle: Long, @@ -399,8 +503,7 @@ object MtprotoNativeUniFfi : MtprotoNative { chatId: Long, query: String, limit: Int, - ): List = - nativeSearchMessages(handle.toULong(), chatId, query, limit) + ): List = nativeSearchMessages(handle.toULong(), chatId, query, limit) override fun searchMessagesFiltered( handle: Long, @@ -469,35 +572,39 @@ object MtprotoNativeUniFfi : MtprotoNative { handle: Long, chatId: Long, limit: Int, - ): List = - nativeGetPinnedMessages(handle.toULong(), chatId, limit) + ): List = nativeGetPinnedMessages(handle.toULong(), chatId, limit) override fun getMessageReadParticipants( handle: Long, chatId: Long, msgId: Int, - ): ReadParticipantsDto = - nativeGetMessageReadParticipants(handle.toULong(), chatId, msgId) + ): ReadParticipantsDto = nativeGetMessageReadParticipants(handle.toULong(), chatId, msgId) - override fun getMessageReactionsPage(handle: Long, chatId: Long, messageId: Int, offset: String?) = - uniffi.monogram_mtproto.getMessageReactionsPage(handle.toULong(), chatId, messageId, offset) + override fun getMessageReactionsPage( + handle: Long, + chatId: Long, + messageId: Int, + offset: String?, + ) = uniffi.monogram_mtproto.getMessageReactionsPage(handle.toULong(), chatId, messageId, offset) - override fun getPollVotesPage(handle: Long, chatId: Long, messageId: Int, offset: String?) = - uniffi.monogram_mtproto.getPollVotesPage(handle.toULong(), chatId, messageId, offset) + override fun getPollVotesPage( + handle: Long, + chatId: Long, + messageId: Int, + offset: String?, + ) = uniffi.monogram_mtproto.getPollVotesPage(handle.toULong(), chatId, messageId, offset) override fun getMessageReactionsList( handle: Long, chatId: Long, messageId: Int, - ): ReactionPeersDto = - nativeGetMessageReactionsList(handle.toULong(), chatId, messageId) + ): ReactionPeersDto = nativeGetMessageReactionsList(handle.toULong(), chatId, messageId) override fun getPollVotes( handle: Long, chatId: Long, messageId: Int, - ): PollVotersDto = - nativeGetPollVotes(handle.toULong(), chatId, messageId) + ): PollVotersDto = nativeGetPollVotes(handle.toULong(), chatId, messageId) override fun getOutboxReadDate( handle: Long, @@ -508,59 +615,174 @@ object MtprotoNativeUniFfi : MtprotoNative { override fun getReadReceiptConfig(handle: Long): ReadReceiptConfigDto = nativeGetReadReceiptConfig(handle.toULong()) - override fun importContacts(handle: Long, contacts: List) = - uniffi.monogram_mtproto.importContacts(handle.toULong(), contacts) + override fun importContacts( + handle: Long, + contacts: List, + ) = uniffi.monogram_mtproto.importContacts(handle.toULong(), contacts) - override fun sendLocationWithOptions(handle:Long,chatId:Long,spec:uniffi.monogram_mtproto.LocationSendSpec,replyToMsgId:Int,topMsgId:Int,randomId:Long,options:uniffi.monogram_mtproto.SendOptions) = - uniffi.monogram_mtproto.sendLocationWithOptions(handle.toULong(),chatId,spec,replyToMsgId,topMsgId,randomId,options) - override fun sendContactWithOptions(handle: Long, chatId: Long, spec: uniffi.monogram_mtproto.ContactSendSpec, replyToMsgId: Int, topMsgId: Int, - randomId: Long, options: uniffi.monogram_mtproto.SendOptions) = - uniffi.monogram_mtproto.sendContactWithOptions(handle.toULong(), chatId, spec, replyToMsgId, topMsgId, randomId, options) + override fun sendLocationWithOptions( + handle: Long, + chatId: Long, + spec: uniffi.monogram_mtproto.LocationSendSpec, + replyToMsgId: Int, + topMsgId: Int, + randomId: Long, + options: uniffi.monogram_mtproto.SendOptions, + ) = uniffi.monogram_mtproto.sendLocationWithOptions( + handle.toULong(), + chatId, + spec, + replyToMsgId, + topMsgId, + randomId, + options + ) - override fun sendChecklistWithOptions(handle: Long, chatId: Long, spec: uniffi.monogram_mtproto.ChecklistSendSpec, replyToMsgId: Int, topMsgId: Int, - randomId: Long, options: uniffi.monogram_mtproto.SendOptions) = - uniffi.monogram_mtproto.sendChecklistWithOptions(handle.toULong(), chatId, spec, replyToMsgId, topMsgId, randomId, options) + override fun sendContactWithOptions( + handle: Long, + chatId: Long, + spec: uniffi.monogram_mtproto.ContactSendSpec, + replyToMsgId: Int, + topMsgId: Int, + randomId: Long, + options: uniffi.monogram_mtproto.SendOptions, + ) = uniffi.monogram_mtproto.sendContactWithOptions( + handle.toULong(), + chatId, + spec, + replyToMsgId, + topMsgId, + randomId, + options + ) - override fun sendPollWithOptions(handle: Long, chatId: Long, spec: uniffi.monogram_mtproto.PollSendSpec, replyToMsgId: Int, topMsgId: Int, - randomId: Long, options: uniffi.monogram_mtproto.SendOptions) = - uniffi.monogram_mtproto.sendPollWithOptions(handle.toULong(), chatId, spec, replyToMsgId, topMsgId, randomId, options) + override fun sendChecklistWithOptions( + handle: Long, + chatId: Long, + spec: uniffi.monogram_mtproto.ChecklistSendSpec, + replyToMsgId: Int, + topMsgId: Int, + randomId: Long, + options: uniffi.monogram_mtproto.SendOptions, + ) = uniffi.monogram_mtproto.sendChecklistWithOptions( + handle.toULong(), + chatId, + spec, + replyToMsgId, + topMsgId, + randomId, + options + ) - override fun sendDiceWithOptions(handle: Long, chatId: Long, emoticon: String, replyToMsgId: Int, topMsgId: Int, - randomId: Long, options: uniffi.monogram_mtproto.SendOptions) = - uniffi.monogram_mtproto.sendDiceWithOptions(handle.toULong(), chatId, emoticon, replyToMsgId, topMsgId, randomId, options) + override fun sendPollWithOptions( + handle: Long, + chatId: Long, + spec: uniffi.monogram_mtproto.PollSendSpec, + replyToMsgId: Int, + topMsgId: Int, + randomId: Long, + options: uniffi.monogram_mtproto.SendOptions, + ) = uniffi.monogram_mtproto.sendPollWithOptions( + handle.toULong(), + chatId, + spec, + replyToMsgId, + topMsgId, + randomId, + options + ) - override fun sendTextMessageWithOptions( - handle: Long, chatId: Long, text: String, replyToMsgId: Int, - entitiesJson: String?, topMsgId: Int, webpageUrl: String?, randomId: Long, + override fun sendDiceWithOptions( + handle: Long, + chatId: Long, + emoticon: String, + replyToMsgId: Int, + topMsgId: Int, + randomId: Long, options: uniffi.monogram_mtproto.SendOptions, - ): uniffi.monogram_mtproto.OutgoingMessageResult = uniffi.monogram_mtproto.sendTextMessageWithOptions( - handle.toULong(), chatId, text, replyToMsgId, entitiesJson, topMsgId, webpageUrl, randomId, options, + ) = uniffi.monogram_mtproto.sendDiceWithOptions( + handle.toULong(), + chatId, + emoticon, + replyToMsgId, + topMsgId, + randomId, + options ) - override fun getScheduledHistory(handle: Long, chatId: Long): List = + override fun sendTextMessageWithOptions( + handle: Long, + chatId: Long, + text: String, + replyToMsgId: Int, + entitiesJson: String?, + topMsgId: Int, + webpageUrl: String?, + randomId: Long, + options: uniffi.monogram_mtproto.SendOptions, + ): uniffi.monogram_mtproto.OutgoingMessageResult = + uniffi.monogram_mtproto.sendTextMessageWithOptions( + handle.toULong(), + chatId, + text, + replyToMsgId, + entitiesJson, + topMsgId, + webpageUrl, + randomId, + options, + ) + + override fun getScheduledHistory( + handle: Long, + chatId: Long, + ): List = uniffi.monogram_mtproto.getScheduledHistory(handle.toULong(), chatId) - override fun getScheduledMessages(handle: Long, chatId: Long, ids: List): List = + override fun getScheduledMessages( + handle: Long, + chatId: Long, + ids: List, + ): List = uniffi.monogram_mtproto.getScheduledMessages(handle.toULong(), chatId, ids) - override fun sendScheduledMessages(handle: Long, chatId: Long, ids: List) = - uniffi.monogram_mtproto.sendScheduledMessages(handle.toULong(), chatId, ids) + override fun sendScheduledMessages( + handle: Long, + chatId: Long, + ids: List, + ) = uniffi.monogram_mtproto.sendScheduledMessages(handle.toULong(), chatId, ids) - override fun deleteScheduledMessages(handle: Long, chatId: Long, ids: List) = - uniffi.monogram_mtproto.deleteScheduledMessages(handle.toULong(), chatId, ids) + override fun deleteScheduledMessages( + handle: Long, + chatId: Long, + ids: List, + ) = uniffi.monogram_mtproto.deleteScheduledMessages(handle.toULong(), chatId, ids) override fun editScheduledMessage( - handle: Long, chatId: Long, messageId: Int, scheduleDate: Int, - text: String?, entitiesJson: String?, noWebpage: Boolean, + handle: Long, + chatId: Long, + messageId: Int, + scheduleDate: Int, + text: String?, + entitiesJson: String?, + noWebpage: Boolean, ) = uniffi.monogram_mtproto.editScheduledMessage( - handle.toULong(), chatId, messageId, scheduleDate, text, entitiesJson, noWebpage, + handle.toULong(), + chatId, + messageId, + scheduleDate, + text, + entitiesJson, + noWebpage, ) override fun getCloudDrafts(handle: Long): List = uniffi.monogram_mtproto.getCloudDrafts(handle.toULong()) - override fun saveCloudDraft(handle: Long, draft: uniffi.monogram_mtproto.CloudDraft) = - uniffi.monogram_mtproto.saveCloudDraft(handle.toULong(), draft) + override fun saveCloudDraft( + handle: Long, + draft: uniffi.monogram_mtproto.CloudDraft, + ) = uniffi.monogram_mtproto.saveCloudDraft(handle.toULong(), draft) override fun sendTextMessage( handle: Long, @@ -591,11 +813,24 @@ object MtprotoNativeUniFfi : MtprotoNative { replyToMsgId: Int, topMsgId: Int, entitiesJson: String?, - ): MessageDto = - nativeSendPhotoMessage(handle.toULong(), chatId, path, caption, replyToMsgId, topMsgId, entitiesJson) + ): MessageDto = nativeSendPhotoMessage( + handle.toULong(), + chatId, + path, + caption, + replyToMsgId, + topMsgId, + entitiesJson + ) - override fun replaceUploadedMedia(handle:Long,chatId:Long,messageId:Int,expectedKey:String,item:UploadItemDto):MessageDto = - nativeReplaceUploadedMedia(handle.toULong(),chatId,messageId,expectedKey,item) + override fun replaceUploadedMedia( + handle: Long, + chatId: Long, + messageId: Int, + expectedKey: String, + item: UploadItemDto, + ): MessageDto = + nativeReplaceUploadedMedia(handle.toULong(), chatId, messageId, expectedKey, item) override fun sendUploadedMedia( handle: Long, @@ -630,16 +865,40 @@ object MtprotoNativeUniFfi : MtprotoNative { ) override fun sendUploadedMediaWithOptions( - handle: Long, chatId: Long, item: UploadItemDto, replyToMsgId: Int, topMsgId: Int, - entitiesJson: String?, options: uniffi.monogram_mtproto.SendOptions, - ): uniffi.monogram_mtproto.OutgoingMessageResult = nativeSendUploadedMediaWithOptions( - handle.toULong(), chatId, item, replyToMsgId, topMsgId, entitiesJson, options) + handle: Long, + chatId: Long, + item: UploadItemDto, + replyToMsgId: Int, + topMsgId: Int, + entitiesJson: String?, + options: uniffi.monogram_mtproto.SendOptions, + ): uniffi.monogram_mtproto.OutgoingMessageResult = + nativeSendUploadedMediaWithOptions( + handle.toULong(), + chatId, + item, + replyToMsgId, + topMsgId, + entitiesJson, + options + ) override fun sendUploadedAlbumWithOptions( - handle: Long, chatId: Long, items: List, replyToMsgId: Int, topMsgId: Int, + handle: Long, + chatId: Long, + items: List, + replyToMsgId: Int, + topMsgId: Int, options: uniffi.monogram_mtproto.SendOptions, - ): List = nativeSendUploadedAlbumWithOptions( - handle.toULong(), chatId, items, replyToMsgId, topMsgId, options) + ): List = + nativeSendUploadedAlbumWithOptions( + handle.toULong(), + chatId, + items, + replyToMsgId, + topMsgId, + options + ) override fun editTextMessage( handle: Long, @@ -647,8 +906,7 @@ object MtprotoNativeUniFfi : MtprotoNative { messageId: Int, text: String, entitiesJson: String?, - ): MessageDto = - nativeEditTextMessage(handle.toULong(), chatId, messageId, text, entitiesJson) + ): MessageDto = nativeEditTextMessage(handle.toULong(), chatId, messageId, text, entitiesJson) override fun forwardMessages( handle: Long, @@ -668,15 +926,27 @@ object MtprotoNativeUniFfi : MtprotoNative { nativeDeleteMessage(handle.toULong(), chatId, messageId, revoke) } - override fun readHistory(handle: Long, chatId: Long, maxId: Int) { + override fun readHistory( + handle: Long, + chatId: Long, + maxId: Int, + ) { nativeReadHistory(handle.toULong(), chatId, maxId) } - override fun readMessageContents(handle: Long, chatId: Long, messageIds: List) { + override fun readMessageContents( + handle: Long, + chatId: Long, + messageIds: List, + ) { nativeReadMessageContents(handle.toULong(), chatId, messageIds) } - override fun markDialogUnread(handle: Long, chatId: Long, unread: Boolean) { + override fun markDialogUnread( + handle: Long, + chatId: Long, + unread: Boolean, + ) { nativeMarkDialogUnread(handle.toULong(), chatId, unread) } @@ -690,7 +960,11 @@ object MtprotoNativeUniFfi : MtprotoNative { ): List = nativeGetUnreadMentions(handle.toULong(), chatId, offsetId, addOffset, limit, topMsgId) - override fun readMentions(handle: Long, chatId: Long, topMsgId: Int) { + override fun readMentions( + handle: Long, + chatId: Long, + topMsgId: Int, + ) { nativeReadMentions(handle.toULong(), chatId, topMsgId) } @@ -704,19 +978,35 @@ object MtprotoNativeUniFfi : MtprotoNative { ): List = nativeGetUnreadReactions(handle.toULong(), chatId, offsetId, addOffset, limit, topMsgId) - override fun readReactions(handle: Long, chatId: Long, topMsgId: Int) { + override fun readReactions( + handle: Long, + chatId: Long, + topMsgId: Int, + ) { nativeReadReactions(handle.toULong(), chatId, topMsgId) } - override fun readDiscussion(handle: Long, chatId: Long, msgId: Int, readMaxId: Int) { + override fun readDiscussion( + handle: Long, + chatId: Long, + msgId: Int, + readMaxId: Int, + ) { nativeReadDiscussion(handle.toULong(), chatId, msgId, readMaxId) } - override fun setTyping(handle: Long, chatId: Long, typing: Boolean) { + override fun setTyping( + handle: Long, + chatId: Long, + typing: Boolean, + ) { nativeSetTyping(handle.toULong(), chatId, typing) } - override fun updateStatus(handle: Long, offline: Boolean) { + override fun updateStatus( + handle: Long, + offline: Boolean, + ) { nativeUpdateStatus(handle.toULong(), offline) } @@ -741,8 +1031,11 @@ object MtprotoNativeUniFfi : MtprotoNative { nativeUnregisterDevice(handle.toULong(), tokenType, token, otherUids) } - override fun getNotifySettings(handle: Long, peerKind: String, chatId: Long): NotifySettingsDto = - nativeGetNotifySettings(handle.toULong(), peerKind, chatId) + override fun getNotifySettings( + handle: Long, + peerKind: String, + chatId: Long, + ): NotifySettingsDto = nativeGetNotifySettings(handle.toULong(), peerKind, chatId) override fun updateNotifySettings( handle: Long, @@ -770,7 +1063,10 @@ object MtprotoNativeUniFfi : MtprotoNative { nativeResetNotifySettings(handle.toULong()) } - override fun joinChat(handle: Long, chatId: Long): NativeJoinResult { + override fun joinChat( + handle: Long, + chatId: Long, + ): NativeJoinResult { val joined = nativeJoinChat(handle.toULong(), chatId) return NativeJoinResult( joined = joined.joined, @@ -780,28 +1076,45 @@ object MtprotoNativeUniFfi : MtprotoNative { ) } - override fun unblockUser(handle: Long, chatId: Long) { + override fun unblockUser( + handle: Long, + chatId: Long, + ) { nativeUnblockUser(handle.toULong(), chatId) } - override fun setContactJoinedSilent(handle: Long, silent: Boolean) { + override fun setContactJoinedSilent( + handle: Long, + silent: Boolean, + ) { nativeSetContactJoinedSilent(handle.toULong(), silent) } - override fun getNotifyExceptions(handle: Long, compareSound: Boolean): List = - nativeGetNotifyExceptions(handle.toULong(), compareSound) + override fun getNotifyExceptions( + handle: Long, + compareSound: Boolean, + ): List = nativeGetNotifyExceptions(handle.toULong(), compareSound) - override fun decryptPushPayload(secret: ByteArray, payload: String): String = - nativeDecryptPushPayload(secret, payload) + override fun decryptPushPayload( + secret: ByteArray, + payload: String, + ): String = nativeDecryptPushPayload(secret, payload) - override fun getProfile(handle: Long, peerId: Long): ProfileDto = - nativeGetProfile(handle.toULong(), peerId) + override fun getProfile( + handle: Long, + peerId: Long, + ): ProfileDto = nativeGetProfile(handle.toULong(), peerId) - override fun getGroupAdminTags(handle: Long, chatId: Long): String = - nativeGetGroupAdminTags(handle.toULong(), chatId) + override fun getGroupAdminTags( + handle: Long, + chatId: Long, + ): String = nativeGetGroupAdminTags(handle.toULong(), chatId) - override fun getSearchCounters(handle: Long, chatId: Long, filters: List): String = - nativeGetSearchCounters(handle.toULong(), chatId, filters) + override fun getSearchCounters( + handle: Long, + chatId: Long, + filters: List, + ): String = nativeGetSearchCounters(handle.toULong(), chatId, filters) override fun getParticipants( handle: Long, @@ -810,17 +1123,24 @@ object MtprotoNativeUniFfi : MtprotoNative { query: String, offset: Int, limit: Int, - ): String = - nativeGetParticipants(handle.toULong(), chatId, filter, query, offset, limit) + ): String = nativeGetParticipants(handle.toULong(), chatId, filter, query, offset, limit) - override fun getCommonChats(handle: Long, userId: Long, maxId: Long, limit: Int): String = - nativeGetCommonChats(handle.toULong(), userId, maxId, limit) + override fun getCommonChats( + handle: Long, + userId: Long, + maxId: Long, + limit: Int, + ): String = nativeGetCommonChats(handle.toULong(), userId, maxId, limit) override fun startUpdates(handle: Long) { nativeStartUpdates(handle.toULong()) } - override fun updateLazySyncConfig(handle: Long, lazy: Boolean, exceptions: List) { + override fun updateLazySyncConfig( + handle: Long, + lazy: Boolean, + exceptions: List, + ) { nativeUpdateLazySyncConfig(handle.toULong(), lazy, exceptions) } @@ -837,7 +1157,11 @@ object MtprotoNativeUniFfi : MtprotoNative { override fun pendingUpdateBatches(handle: Long): List = nativePendingUpdateBatches(handle.toULong()) - override fun acknowledgeUpdateBatch(handle: Long, accountGeneration: Long, batchId: Long) { + override fun acknowledgeUpdateBatch( + handle: Long, + accountGeneration: Long, + batchId: Long, + ) { nativeAcknowledgeUpdateBatch(handle.toULong(), accountGeneration, batchId) } @@ -861,8 +1185,10 @@ object MtprotoNativeUniFfi : MtprotoNative { messageId: Int, ): ByteArray? = nativePeekMessageInlineThumb(handle.toULong(), chatId, messageId) - override fun peekPeerInlineThumb(handle: Long, peerId: Long): ByteArray? = - nativePeekPeerInlineThumb(handle.toULong(), peerId) + override fun peekPeerInlineThumb( + handle: Long, + peerId: Long, + ): ByteArray? = nativePeekPeerInlineThumb(handle.toULong(), peerId) override fun downloadMessageDisplay( handle: Long, @@ -888,11 +1214,15 @@ object MtprotoNativeUniFfi : MtprotoNative { accessHash: Long, ): StickerPackDto = nativeGetStickerSet(handle.toULong(), setId, accessHash) - override fun getAllStickers(handle: Long, hash: Long): StickerCatalogDto = - nativeGetAllStickers(handle.toULong(), hash) + override fun getAllStickers( + handle: Long, + hash: Long, + ): StickerCatalogDto = nativeGetAllStickers(handle.toULong(), hash) - override fun getEmojiStickers(handle: Long, hash: Long): StickerCatalogDto = - nativeGetEmojiStickers(handle.toULong(), hash) + override fun getEmojiStickers( + handle: Long, + hash: Long, + ): StickerCatalogDto = nativeGetEmojiStickers(handle.toULong(), hash) override fun getStickers( handle: Long, @@ -900,8 +1230,10 @@ object MtprotoNativeUniFfi : MtprotoNative { hash: Long, ): StickerListDto = nativeGetStickers(handle.toULong(), emoticon, hash) - override fun resolveUsername(handle: Long, username: String): ResolvedPeerDto = - nativeResolveUsername(handle.toULong(), username) + override fun resolveUsername( + handle: Long, + username: String, + ): ResolvedPeerDto = nativeResolveUsername(handle.toULong(), username) override fun getInlineBotResults( handle: Long, @@ -919,14 +1251,15 @@ object MtprotoNativeUniFfi : MtprotoNative { resultId: String, replyToMsgId: Int, topMsgId: Int, - ): MessageDto = nativeSendInlineBotResult( - handle.toULong(), - chatId, - queryId, - resultId, - replyToMsgId, - topMsgId, - ) + ): MessageDto = + nativeSendInlineBotResult( + handle.toULong(), + chatId, + queryId, + resultId, + replyToMsgId, + topMsgId, + ) override fun getSavedGifs(handle: Long): List = nativeGetSavedGifs(handle.toULong()) @@ -949,8 +1282,24 @@ object MtprotoNativeUniFfi : MtprotoNative { nativeToggleTodoCompleted(handle.toULong(), chatId, messageId, completed, incompleted) } - override fun editLiveLocation(handle:Long,chatId:Long,messageId:Int,latitude:Double?,longitude:Double?,heading:Int?,stopped:Boolean) = - uniffi.monogram_mtproto.editLiveLocation(handle.toULong(),chatId,messageId,latitude,longitude,heading,stopped) + override fun editLiveLocation( + handle: Long, + chatId: Long, + messageId: Int, + latitude: Double?, + longitude: Double?, + heading: Int?, + stopped: Boolean, + ) = uniffi.monogram_mtproto.editLiveLocation( + handle.toULong(), + chatId, + messageId, + latitude, + longitude, + heading, + stopped + ) + override fun sendLocation( handle: Long, chatId: Long, @@ -971,16 +1320,32 @@ object MtprotoNativeUniFfi : MtprotoNative { ) } - override fun removeChecklistItem(handle:Long,chatId:Long,messageId:Int,edit:uniffi.monogram_mtproto.ChecklistTextEditDto) = - uniffi.monogram_mtproto.removeChecklistItem(handle.toULong(),chatId,messageId,edit) - override fun editChecklistText(handle: Long, chatId: Long, messageId: Int, edit: uniffi.monogram_mtproto.ChecklistTextEditDto) = - uniffi.monogram_mtproto.editChecklistText(handle.toULong(),chatId,messageId,edit) + override fun removeChecklistItem( + handle: Long, + chatId: Long, + messageId: Int, + edit: uniffi.monogram_mtproto.ChecklistTextEditDto, + ) = uniffi.monogram_mtproto.removeChecklistItem(handle.toULong(), chatId, messageId, edit) + + override fun editChecklistText( + handle: Long, + chatId: Long, + messageId: Int, + edit: uniffi.monogram_mtproto.ChecklistTextEditDto, + ) = uniffi.monogram_mtproto.editChecklistText(handle.toULong(), chatId, messageId, edit) - override fun editChecklistTitle(handle: Long, chatId: Long, messageId: Int, title: String) = - uniffi.monogram_mtproto.editChecklistTitle(handle.toULong(), chatId, messageId, title) + override fun editChecklistTitle( + handle: Long, + chatId: Long, + messageId: Int, + title: String, + ) = uniffi.monogram_mtproto.editChecklistTitle(handle.toULong(), chatId, messageId, title) - override fun closePoll(handle: Long, chatId: Long, messageId: Int) = - uniffi.monogram_mtproto.closePoll(handle.toULong(), chatId, messageId) + override fun closePoll( + handle: Long, + chatId: Long, + messageId: Int, + ) = uniffi.monogram_mtproto.closePoll(handle.toULong(), chatId, messageId) override fun sendPollVote( handle: Long, @@ -1025,11 +1390,12 @@ object MtprotoNativeUniFfi : MtprotoNative { messageId: Int, ): DiscussionDto = nativeGetDiscussionMessage(handle.toULong(), chatId, messageId) - override fun animatedEmojiMax(handle: Long): Int = - nativeAnimatedEmojiMax(handle.toULong()) + override fun animatedEmojiMax(handle: Long): Int = nativeAnimatedEmojiMax(handle.toULong()) - override fun customEmojiIsFree(handle: Long, documentId: Long): Boolean = - nativeCustomEmojiIsFree(handle.toULong(), documentId) + override fun customEmojiIsFree( + handle: Long, + documentId: Long, + ): Boolean = nativeCustomEmojiIsFree(handle.toULong(), documentId) override fun getRecentReactions(handle: Long): List = nativeGetRecentReactions(handle.toULong()) diff --git a/native/mtproto/src/main/java/org/monogram/mtproto/SessionKeyStore.kt b/native/mtproto/src/main/java/org/monogram/mtproto/SessionKeyStore.kt index 6ef95cbdc..8b088f5b4 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/SessionKeyStore.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/SessionKeyStore.kt @@ -2,9 +2,14 @@ package org.monogram.mtproto import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import android.system.StructStat import android.util.AtomicFile -import java.io.File +import com.sun.jna.NativeLibrary import java.io.DataInputStream +import java.io.File import java.security.KeyStore import java.security.MessageDigest import java.security.SecureRandom @@ -22,7 +27,102 @@ internal object SessionKeyStore { private val legacySessionMagic = "MONORE_SESSION\u0001".toByteArray(Charsets.US_ASCII) @Synchronized - fun loadOrCreate(sessionPath: String): ByteArray { + fun loadOrCreate(sessionPath: String): ByteArray = loadOrCreate(sessionPath, null) + + @Synchronized + fun promote(source: File, destination: File) { + check(source.isFile) { "Session source unavailable" } + val target = destination.canonicalFile + check( + !target.exists() && !File("${target.path}.key").exists() && !File("${target.path}.key.bak").exists() + ) { "Session destination already exists" } + check(target.parentFile?.let { it.isDirectory || it.mkdirs() } == true) + val key = loadOrCreate(source.path) + try { + loadOrCreate(target.path, key).fill(0) + check(source.renameTo(target)) { "Unable to promote account session" } + } catch (failure: Throwable) { + File("${target.path}.key").delete() + throw failure + } finally { + key.fill(0) + } + } + + /** Caller must hold exclusive ownership proven by a durable promotion journal. */ + @Synchronized + fun recoverPromotion(source: File, destination: File) { + check(!isSymlink(source) && !isSymlink(destination)) { "Symbolic session path" } + val from = source.canonicalFile + val to = destination.canonicalFile + val sourceKeyFile = File("${from.path}.key") + val targetKeyFile = File("${to.path}.key") + check(setOf(from.path, to.path, sourceKeyFile.path, targetKeyFile.path).size == 4) { + "Overlapping session paths" + } + for (key in listOf(sourceKeyFile, targetKeyFile)) { + check(stat(File("${key.path}.bak")) == null && stat(File("${key.path}.new")) == null) { + "Ambiguous wrapped key state" + } + val keyStat = stat(key) + check(keyStat != null && OsConstants.S_ISREG(keyStat.st_mode)) { "Wrapped session key unavailable" } + } + val sourceStat = stat(from) + val targetStat = stat(to) + val beforeRename = + sourceStat != null && OsConstants.S_ISREG(sourceStat.st_mode) && targetStat == null + val afterRename = + sourceStat == null && targetStat != null && OsConstants.S_ISREG(targetStat.st_mode) + check(beforeRename || afterRename) { "Ambiguous session state" } + + val sourceKey = readExistingWrappedKey(from.path, sourceKeyFile) + try { + val targetKey = readExistingWrappedKey(to.path, targetKeyFile) + try { + check(MessageDigest.isEqual(sourceKey, targetKey)) { "Session keys do not match" } + } finally { + targetKey.fill(0) + } + } finally { + sourceKey.fill(0) + } + if (beforeRename) { + // renameat2 refuses to replace any destination, including one created concurrently. + val rename = NativeLibrary.getInstance("c").getFunction("renameat2") + check(rename.invokeInt(arrayOf(-100, from.path, -100, to.path, 1)) == 0) { + "Unable to recover account session without replacement" + } + } + } + + private fun stat(file: File): StructStat? = try { + Os.lstat(file.path) + } catch (error: ErrnoException) { + if (error.errno == OsConstants.ENOENT) null else throw error + } + + private fun isSymlink(file: File): Boolean = + stat(file)?.let { OsConstants.S_ISLNK(it.st_mode) } ?: false + + private fun readExistingWrappedKey(path: String, file: File): ByteArray { + check(file.length() == 61L) { "Invalid wrapped session key" } + val bytes = file.readBytes() + check(bytes.size == 1 + 12 + 32 + 16 && bytes[0] == 1.toByte()) { "Invalid wrapped session key" } + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val wrappingKey = (keyStore.getKey(alias(path, ALIAS_PREFIX), null) ?: keyStore.getKey( + alias( + path, LEGACY_ALIAS_PREFIX + ), null + )) as? SecretKey ?: error("Session wrapping key unavailable") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, wrappingKey, GCMParameterSpec(128, bytes.copyOfRange(1, 13)) + ) + cipher.updateAAD(path.toByteArray(Charsets.UTF_8)) + return cipher.doFinal(bytes, 13, bytes.size - 13).also { check(it.size == 32) } + } + + private fun loadOrCreate(sessionPath: String, importedKey: ByteArray?): ByteArray { val path = File(sessionPath).canonicalPath val currentAlias = alias(path, ALIAS_PREFIX) val legacyAlias = alias(path, LEGACY_ALIAS_PREFIX) @@ -32,27 +132,27 @@ internal object SessionKeyStore { val existing = if (wrapped.baseFile.exists() || File("$path.key.bak").exists()) { wrapped.readFully() } else null - val wrappingKey = keyStore.getKey(currentAlias, null) as? SecretKey - ?: keyStore.getKey(legacyAlias, null) as? SecretKey - ?: run { - check(existing == null) { "Session wrapping key unavailable" } - KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { - init( - KeyGenParameterSpec.Builder( - currentAlias, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) - .build(), - ) - }.generateKey() - } + val wrappingKey = keyStore.getKey(currentAlias, null) as? SecretKey ?: keyStore.getKey( + legacyAlias, null + ) as? SecretKey ?: run { + check(existing == null) { "Session wrapping key unavailable" } + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init( + KeyGenParameterSpec.Builder( + currentAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256).build(), + ) + }.generateKey() + } val cipher = Cipher.getInstance("AES/GCM/NoPadding") if (existing != null) { check(existing.size == 1 + 12 + 32 + 16 && existing[0] == 1.toByte()) - cipher.init(Cipher.DECRYPT_MODE, wrappingKey, GCMParameterSpec(128, existing.copyOfRange(1, 13))) + cipher.init( + Cipher.DECRYPT_MODE, wrappingKey, GCMParameterSpec(128, existing.copyOfRange(1, 13)) + ) cipher.updateAAD(path.toByteArray(Charsets.UTF_8)) return cipher.doFinal(existing, 13, existing.size - 13).also { check(it.size == 32) } } @@ -67,12 +167,12 @@ internal object SessionKeyStore { ByteArray(n).also(stream::readFully) } check( - !prefix.startsWith(sessionMagic) && - !prefix.startsWith(brandedSessionMagic) && - !prefix.startsWith(legacySessionMagic), + !prefix.startsWith(sessionMagic) && !prefix.startsWith(brandedSessionMagic) && !prefix.startsWith( + legacySessionMagic + ), ) } - val key = ByteArray(32).also(SecureRandom()::nextBytes) + val key = importedKey?.copyOf() ?: ByteArray(32).also(SecureRandom()::nextBytes) try { cipher.init(Cipher.ENCRYPT_MODE, wrappingKey) cipher.updateAAD(path.toByteArray(Charsets.UTF_8)) @@ -93,8 +193,8 @@ internal object SessionKeyStore { } private fun alias(path: String, prefix: String): String = - prefix + MessageDigest.getInstance("SHA-256") - .digest(path.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + prefix + MessageDigest.getInstance("SHA-256").digest(path.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } private fun ByteArray.startsWith(other: ByteArray): Boolean { if (size < other.size) return false diff --git a/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt b/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt index 413481dde..4678ed3b6 100644 --- a/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt +++ b/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt @@ -778,6 +778,8 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_monogram_mtproto_checksum_func_download_message_media( ): Int + external fun uniffi_monogram_mtproto_checksum_func_download_message_media_checked( + ): Int external fun uniffi_monogram_mtproto_checksum_func_download_message_media_chunk( ): Int external fun uniffi_monogram_mtproto_checksum_func_download_message_thumb( @@ -1160,6 +1162,8 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_download_message_media(`handle`: Long,`chatId`: Long,`messageId`: Int,`destPath`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_monogram_mtproto_fn_func_download_message_media_checked(`handle`: Long,`chatId`: Long,`messageId`: Int,`destPath`: RustBuffer.ByValue,`expectedCacheKey`: RustBuffer.ByValue,`kind`: RustBuffer.ByValue,`offset`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_download_message_media_chunk(`handle`: Long,`chatId`: Long,`messageId`: Int,`destPath`: RustBuffer.ByValue,`offset`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_download_message_thumb(`handle`: Long,`chatId`: Long,`messageId`: Int,`destPath`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1682,6 +1686,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_download_message_media() and 0xFFFF) != 18708) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_download_message_media_checked() and 0xFFFF) != 55670) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_download_message_media_chunk() and 0xFFFF) != 46836) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -2724,6 +2731,8 @@ data class ChatDto ( , var `photoCacheKey`: kotlin.String? , + var `photoKnown`: kotlin.Boolean = false + , var `pinned`: kotlin.Boolean , var `readInboxMaxId`: kotlin.Int @@ -2816,6 +2825,7 @@ public object FfiConverterTypeChatDto: FfiConverterRustBuffer { FfiConverterBoolean.read(buf), FfiConverterOptionalString.read(buf), FfiConverterBoolean.read(buf), + FfiConverterBoolean.read(buf), FfiConverterInt.read(buf), FfiConverterInt.read(buf), FfiConverterOptionalString.read(buf), @@ -2854,6 +2864,7 @@ public object FfiConverterTypeChatDto: FfiConverterRustBuffer { FfiConverterBoolean.allocationSize(value.`isBot`) + FfiConverterBoolean.allocationSize(value.`isVerified`) + FfiConverterOptionalString.allocationSize(value.`photoCacheKey`) + + FfiConverterBoolean.allocationSize(value.`photoKnown`) + FfiConverterBoolean.allocationSize(value.`pinned`) + FfiConverterInt.allocationSize(value.`readInboxMaxId`) + FfiConverterInt.allocationSize(value.`readOutboxMaxId`) + @@ -2892,6 +2903,7 @@ public object FfiConverterTypeChatDto: FfiConverterRustBuffer { FfiConverterBoolean.write(value.`isBot`, buf) FfiConverterBoolean.write(value.`isVerified`, buf) FfiConverterOptionalString.write(value.`photoCacheKey`, buf) + FfiConverterBoolean.write(value.`photoKnown`, buf) FfiConverterBoolean.write(value.`pinned`, buf) FfiConverterInt.write(value.`readInboxMaxId`, buf) FfiConverterInt.write(value.`readOutboxMaxId`, buf) @@ -4921,6 +4933,8 @@ data class ProfileDto ( , var `avatarCacheKey`: kotlin.String? , + var `avatarKnown`: kotlin.Boolean = false + , var `isSelf`: kotlin.Boolean , var `status`: kotlin.String? @@ -4954,6 +4968,7 @@ public object FfiConverterTypeProfileDto: FfiConverterRustBuffer { FfiConverterOptionalString.read(buf), FfiConverterOptionalString.read(buf), FfiConverterBoolean.read(buf), + FfiConverterBoolean.read(buf), FfiConverterOptionalString.read(buf), FfiConverterOptionalLong.read(buf), FfiConverterOptionalString.read(buf), @@ -4967,6 +4982,7 @@ public object FfiConverterTypeProfileDto: FfiConverterRustBuffer { FfiConverterOptionalString.allocationSize(value.`username`) + FfiConverterOptionalString.allocationSize(value.`about`) + FfiConverterOptionalString.allocationSize(value.`avatarCacheKey`) + + FfiConverterBoolean.allocationSize(value.`avatarKnown`) + FfiConverterBoolean.allocationSize(value.`isSelf`) + FfiConverterOptionalString.allocationSize(value.`status`) + FfiConverterOptionalLong.allocationSize(value.`statusAt`) + @@ -4980,6 +4996,7 @@ public object FfiConverterTypeProfileDto: FfiConverterRustBuffer { FfiConverterOptionalString.write(value.`username`, buf) FfiConverterOptionalString.write(value.`about`, buf) FfiConverterOptionalString.write(value.`avatarCacheKey`, buf) + FfiConverterBoolean.write(value.`avatarKnown`, buf) FfiConverterBoolean.write(value.`isSelf`, buf) FfiConverterOptionalString.write(value.`status`, buf) FfiConverterOptionalLong.write(value.`statusAt`, buf) @@ -6460,6 +6477,16 @@ sealed class UpdateEventDto { companion object } + data class MessageContentInvalidated( + val `chatId`: kotlin.Long, + val `messageId`: kotlin.Int) : UpdateEventDto() + + { + + + companion object + } + data class MessageEdited( val `message`: uniffi.monogram_mtproto.MessageDto) : UpdateEventDto() @@ -6630,55 +6657,59 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer UpdateEventDto.NewMessage( FfiConverterTypeMessageDto.read(buf), ) - 9 -> UpdateEventDto.MessageEdited( + 9 -> UpdateEventDto.MessageContentInvalidated( + FfiConverterLong.read(buf), + FfiConverterInt.read(buf), + ) + 10 -> UpdateEventDto.MessageEdited( FfiConverterTypeMessageDto.read(buf), ) - 10 -> UpdateEventDto.MessagesDeleted( + 11 -> UpdateEventDto.MessagesDeleted( FfiConverterOptionalLong.read(buf), FfiConverterSequenceInt.read(buf), ) - 11 -> UpdateEventDto.PeerTyping( + 12 -> UpdateEventDto.PeerTyping( FfiConverterLong.read(buf), FfiConverterLong.read(buf), FfiConverterBoolean.read(buf), FfiConverterString.read(buf), ) - 12 -> UpdateEventDto.PeerStatus( + 13 -> UpdateEventDto.PeerStatus( FfiConverterLong.read(buf), FfiConverterOptionalString.read(buf), FfiConverterOptionalLong.read(buf), ) - 13 -> UpdateEventDto.PeerEmojiStatus( + 14 -> UpdateEventDto.PeerEmojiStatus( FfiConverterLong.read(buf), FfiConverterOptionalLong.read(buf), ) - 14 -> UpdateEventDto.ReadInbox( + 15 -> UpdateEventDto.ReadInbox( FfiConverterLong.read(buf), FfiConverterInt.read(buf), FfiConverterInt.read(buf), ) - 15 -> UpdateEventDto.ReadOutbox( + 16 -> UpdateEventDto.ReadOutbox( FfiConverterLong.read(buf), FfiConverterInt.read(buf), ) - 16 -> UpdateEventDto.SavedGifsChanged - 17 -> UpdateEventDto.MessageReactions( + 17 -> UpdateEventDto.SavedGifsChanged + 18 -> UpdateEventDto.MessageReactions( FfiConverterLong.read(buf), FfiConverterInt.read(buf), FfiConverterString.read(buf), ) - 18 -> UpdateEventDto.DiscussionInbox( + 19 -> UpdateEventDto.DiscussionInbox( FfiConverterLong.read(buf), FfiConverterInt.read(buf), FfiConverterInt.read(buf), ) - 19 -> UpdateEventDto.DialogsPatched( + 20 -> UpdateEventDto.DialogsPatched( FfiConverterSequenceTypeChatDto.read(buf), ) - 20 -> UpdateEventDto.SyncState( + 21 -> UpdateEventDto.SyncState( FfiConverterBoolean.read(buf), ) - 21 -> UpdateEventDto.Ignored( + 22 -> UpdateEventDto.Ignored( FfiConverterString.read(buf), ) else -> throw RuntimeException("invalid enum value, something is very wrong!!") @@ -6743,6 +6774,14 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterLong.allocationSize(value.`chatId`) + + FfiConverterInt.allocationSize(value.`messageId`) + ) + } is UpdateEventDto.MessageEdited -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( @@ -6892,19 +6931,25 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer { + is UpdateEventDto.MessageContentInvalidated -> { buf.putInt(9) + FfiConverterLong.write(value.`chatId`, buf) + FfiConverterInt.write(value.`messageId`, buf) + Unit + } + is UpdateEventDto.MessageEdited -> { + buf.putInt(10) FfiConverterTypeMessageDto.write(value.`message`, buf) Unit } is UpdateEventDto.MessagesDeleted -> { - buf.putInt(10) + buf.putInt(11) FfiConverterOptionalLong.write(value.`chatId`, buf) FfiConverterSequenceInt.write(value.`messageIds`, buf) Unit } is UpdateEventDto.PeerTyping -> { - buf.putInt(11) + buf.putInt(12) FfiConverterLong.write(value.`chatId`, buf) FfiConverterLong.write(value.`userId`, buf) FfiConverterBoolean.write(value.`typing`, buf) @@ -6912,61 +6957,61 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer { - buf.putInt(12) + buf.putInt(13) FfiConverterLong.write(value.`userId`, buf) FfiConverterOptionalString.write(value.`status`, buf) FfiConverterOptionalLong.write(value.`statusAt`, buf) Unit } is UpdateEventDto.PeerEmojiStatus -> { - buf.putInt(13) + buf.putInt(14) FfiConverterLong.write(value.`userId`, buf) FfiConverterOptionalLong.write(value.`documentId`, buf) Unit } is UpdateEventDto.ReadInbox -> { - buf.putInt(14) + buf.putInt(15) FfiConverterLong.write(value.`chatId`, buf) FfiConverterInt.write(value.`maxId`, buf) FfiConverterInt.write(value.`stillUnread`, buf) Unit } is UpdateEventDto.ReadOutbox -> { - buf.putInt(15) + buf.putInt(16) FfiConverterLong.write(value.`chatId`, buf) FfiConverterInt.write(value.`maxId`, buf) Unit } is UpdateEventDto.SavedGifsChanged -> { - buf.putInt(16) + buf.putInt(17) Unit } is UpdateEventDto.MessageReactions -> { - buf.putInt(17) + buf.putInt(18) FfiConverterLong.write(value.`chatId`, buf) FfiConverterInt.write(value.`messageId`, buf) FfiConverterString.write(value.`reactionsJson`, buf) Unit } is UpdateEventDto.DiscussionInbox -> { - buf.putInt(18) + buf.putInt(19) FfiConverterLong.write(value.`channelId`, buf) FfiConverterInt.write(value.`topMessageId`, buf) FfiConverterInt.write(value.`readMaxId`, buf) Unit } is UpdateEventDto.DialogsPatched -> { - buf.putInt(19) + buf.putInt(20) FfiConverterSequenceTypeChatDto.write(value.`chats`, buf) Unit } is UpdateEventDto.SyncState -> { - buf.putInt(20) + buf.putInt(21) FfiConverterBoolean.write(value.`isSyncing`, buf) Unit } is UpdateEventDto.Ignored -> { - buf.putInt(21) + buf.putInt(22) FfiConverterString.write(value.`kind`, buf) Unit } @@ -8816,6 +8861,24 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_media_checked( + + + FfiConverterULong.lower(`handle`), + FfiConverterLong.lower(`chatId`), + FfiConverterInt.lower(`messageId`), + FfiConverterString.lower(`destPath`), + FfiConverterString.lower(`expectedCacheKey`), + FfiConverterString.lower(`kind`), + FfiConverterOptionalLong.lower(`offset`),_status) +} + ) + } + + @Throws(MtprotoException::class) fun `downloadMessageMediaChunk`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `destPath`: kotlin.String, `offset`: kotlin.Long): kotlin.String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/MtprotoClient.kt b/network/bridge/src/main/java/org/monogram/network/bridge/MtprotoClient.kt index 3569229cd..0b4e14666 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/MtprotoClient.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/MtprotoClient.kt @@ -57,7 +57,11 @@ sealed class MtprotoUpdate { data class PollChanged(val pollId: Long) : MtprotoUpdate() data class DraftChanged(val draft: CloudDraft) : MtprotoUpdate() data class MessageEdited(val message: Message) : MtprotoUpdate() - data class MessagesDeleted(val chatId: PeerId?, val messageIds: List) : MtprotoUpdate() + data class MessagesDeleted( + val chatId: PeerId?, + val messageIds: List, + val persisted: Boolean = false, + ) : MtprotoUpdate() data class FoldersChanged(val folders: List) : MtprotoUpdate() data class PeerTyping( val chatId: PeerId, diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatApi.kt b/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatApi.kt index 68d5909ae..6a6e0c409 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatApi.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatApi.kt @@ -7,27 +7,59 @@ import org.monogram.core.models.Folder import org.monogram.core.models.ForumTopicsPage import org.monogram.core.models.PeerId import org.monogram.network.bridge.MtprotoUpdate +import org.monogram.network.bridge.session.DispatchClass import org.monogram.network.bridge.session.SessionCore -internal class ChatApi(private val core: SessionCore) : ChatOps { - override suspend fun getChats(): Outcome> = core.coalesce("getChats") { +internal class ChatApi( + private val core: SessionCore, +) : ChatOps { + override suspend fun getChats(): Outcome> { when (val connected = core.ensureConnected()) { - is Outcome.Err -> connected - is Outcome.Ok -> core.rpc("getChats failed") { activeHandle -> - core.native.getChats(activeHandle).toChatModels() - } + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcResult("getChats failed") { + var chats = sharedChats(idle = false) + while (chats == null) chats = sharedChats(idle = false) + chats } } - override suspend fun getFolders(): Outcome> = core.coalesce("getFolders") { - when (val connected = core.ensureConnected()) { - is Outcome.Err -> connected - is Outcome.Ok -> core.rpcBackground("getFolders failed") { activeHandle -> - core.native.getFolders(activeHandle).map { it.toModel() } - } + internal suspend fun getChatsIfIdle(): List? { + when (core.ensureConnected()) { + is Outcome.Err -> return null + is Outcome.Ok -> Unit } + return sharedChats(idle = true) } + private suspend fun sharedChats(idle: Boolean): List? = + core.coalesce("getChats") { + if (idle) { + // Foreground joiners must not inherit background queue priority. + core.onNativeIfFreeClass(DispatchClass.INTERACTIVE_READ) { + core.native.getChats(it).toChatModels() + } + } else { + core.onNative { core.native.getChats(it).toChatModels() } + } + } + + override suspend fun getFolders(): Outcome> = + core.coalesce("getFolders") { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> { + connected + } + + is Outcome.Ok -> { + core.rpcBackground("getFolders failed") { activeHandle -> + core.native.getFolders(activeHandle).map { it.toModel() } + } + } + } + } + override suspend fun updateFolder(folder: Folder): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected @@ -68,7 +100,8 @@ internal class ChatApi(private val core: SessionCore) : ChatOps { is Outcome.Ok -> Unit } return core.rpc("loadMoreChats failed") { activeHandle -> - core.native.loadMoreChats(activeHandle, offsetDate, offsetId, offsetPeerId, 0) + core.native + .loadMoreChats(activeHandle, offsetDate, offsetId, offsetPeerId, 0) .toChatModels() } } @@ -84,40 +117,51 @@ internal class ChatApi(private val core: SessionCore) : ChatOps { is Outcome.Ok -> Unit } return core.rpc("loadMoreFolderChats failed") { activeHandle -> - core.native.loadMoreChats(activeHandle, offsetDate, offsetId, offsetPeerId, folderId) + core.native + .loadMoreChats(activeHandle, offsetDate, offsetId, offsetPeerId, folderId) .toChatModels() } } - override suspend fun readHistory(chatId: PeerId, maxId: Int): Outcome { + override suspend fun readHistory( + chatId: PeerId, + maxId: Int, + ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected is Outcome.Ok -> Unit } - return core.rpc("readHistory failed") { activeHandle -> - core.native.readHistory(activeHandle, chatId.value, maxId) - }.also { result -> - if (result is Outcome.Ok) core.localUpdates.emit( - MtprotoUpdate.ReadHistoryConfirmed( - chatId, - maxId - ) - ) - } + return core + .rpc("readHistory failed") { activeHandle -> + core.native.readHistory(activeHandle, chatId.value, maxId) + }.also { result -> + if (result is Outcome.Ok) { + core.localUpdates.emit( + MtprotoUpdate.ReadHistoryConfirmed( + chatId, + maxId, + ), + ) + } + } } - override suspend fun markDialogUnread(chatId: PeerId, unread: Boolean): Outcome { + override suspend fun markDialogUnread( + chatId: PeerId, + unread: Boolean, + ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected is Outcome.Ok -> Unit } - return core.rpc("markDialogUnread failed") { activeHandle -> - core.native.markDialogUnread(activeHandle, chatId.value, unread) - }.also { result -> - if (result is Outcome.Ok) { - core.localUpdates.emit(MtprotoUpdate.DialogUnreadMark(chatId, unread)) + return core + .rpc("markDialogUnread failed") { activeHandle -> + core.native.markDialogUnread(activeHandle, chatId.value, unread) + }.also { result -> + if (result is Outcome.Ok) { + core.localUpdates.emit(MtprotoUpdate.DialogUnreadMark(chatId, unread)) + } } - } } override suspend fun readDiscussion( @@ -129,20 +173,26 @@ internal class ChatApi(private val core: SessionCore) : ChatOps { is Outcome.Err -> return connected is Outcome.Ok -> Unit } - return core.rpc("readDiscussion failed") { activeHandle -> - core.native.readDiscussion(activeHandle, chatId.value, msgId, readMaxId) - }.also { result -> - if (result is Outcome.Ok) core.localUpdates.emit( - MtprotoUpdate.DiscussionInbox( - chatId, - msgId, - readMaxId - ) - ) - } + return core + .rpc("readDiscussion failed") { activeHandle -> + core.native.readDiscussion(activeHandle, chatId.value, msgId, readMaxId) + }.also { result -> + if (result is Outcome.Ok) { + core.localUpdates.emit( + MtprotoUpdate.DiscussionInbox( + chatId, + msgId, + readMaxId, + ), + ) + } + } } - override suspend fun setTyping(chatId: PeerId, typing: Boolean): Outcome { + override suspend fun setTyping( + chatId: PeerId, + typing: Boolean, + ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected is Outcome.Ok -> Unit @@ -157,9 +207,11 @@ internal class ChatApi(private val core: SessionCore) : ChatOps { is Outcome.Err -> return connected is Outcome.Ok -> Unit } - if (!core.onNative { activeHandle -> core.native.isAuthorized(activeHandle) }) return Outcome.Ok( - Unit - ) + if (!core.onNative { activeHandle -> core.native.isAuthorized(activeHandle) }) { + return Outcome.Ok( + Unit, + ) + } return core.rpc("account.updateStatus failed") { activeHandle -> core.native.updateStatus(activeHandle, offline) } @@ -194,14 +246,15 @@ internal class ChatApi(private val core: SessionCore) : ChatOps { } AppLog.api("getForumTopics", "start chat=${chatId.value}") return core.rpc("getForumTopics failed") { activeHandle -> - core.native.getForumTopics( - activeHandle, - chatId.value, - offsetDate, - offsetId, - offsetTopic, - limit - ).toModel() + core.native + .getForumTopics( + activeHandle, + chatId.value, + offsetDate, + offsetId, + offsetTopic, + limit, + ).toModel() } } diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/chat/Mappings.kt b/network/bridge/src/main/java/org/monogram/network/bridge/chat/Mappings.kt index de8371b28..d41334ac4 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/chat/Mappings.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/chat/Mappings.kt @@ -31,6 +31,7 @@ internal fun ChatDto.toModel(pinnedOrder: Int = Int.MAX_VALUE): Chat = Chat( pinnedOrder = pinnedOrder, lastMessageDate = lastMessageDate, photoCacheKey = photoCacheKey, + photoKnown = photoKnown, lastMediaThumbCacheKey = lastMediaThumbCacheKey, lastMessageId = lastMessageId, canView = canView, diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/media/MediaApi.kt b/network/bridge/src/main/java/org/monogram/network/bridge/media/MediaApi.kt index 2342e7340..1d9c1d71c 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/media/MediaApi.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/media/MediaApi.kt @@ -18,9 +18,12 @@ import org.monogram.network.bridge.session.isFileMigrateError import org.monogram.network.bridge.session.isMissingThumbError import org.monogram.network.bridge.session.nativeExceptionMessage import org.monogram.network.bridge.session.promoteTaggedRequest +import org.monogram.network.http.MediaFetchKind import org.monogram.network.http.MediaPriority -internal class MediaApi(private val core: SessionCore) : MediaOps { +internal class MediaApi( + private val core: SessionCore, +) : MediaOps { override suspend fun getWallpapers(hash: Long): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected @@ -35,7 +38,7 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { override suspend fun downloadWallpaper( wallpaper: Wallpaper, - destPath: String + destPath: String, ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected @@ -52,8 +55,7 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { chatId: PeerId, messageId: Int, destPath: String, - ): Outcome = - downloadMessageMedia(chatId, messageId, destPath, MediaPriority.DEFAULT) + ): Outcome = downloadMessageMedia(chatId, messageId, destPath, MediaPriority.DEFAULT) override suspend fun downloadMessageMedia( chatId: PeerId, @@ -64,7 +66,10 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { downloadTelegramFile(chatId, messageId, destPath, TelegramFileKind.Full, priority) override suspend fun downloadMessageMediaChunk( - chatId: PeerId, messageId: Int, destPath: String, offset: Long, + chatId: PeerId, + messageId: Int, + destPath: String, + offset: Long, ): Outcome { require(offset >= 0 && offset % (512 * 1024) == 0L) when (val connected = core.ensureConnected()) { @@ -80,7 +85,7 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { chatId.value, messageId, destPath, - offset + offset, ) } AppLog.api("stream media part", "ok") @@ -97,8 +102,7 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { chatId: PeerId, messageId: Int, destPath: String, - ): Outcome = - downloadMessageThumb(chatId, messageId, destPath, MediaPriority.DEFAULT) + ): Outcome = downloadMessageThumb(chatId, messageId, destPath, MediaPriority.DEFAULT) override suspend fun downloadMessageThumb( chatId: PeerId, @@ -112,8 +116,7 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { chatId: PeerId, messageId: Int, destPath: String, - ): Outcome = - downloadMessageDisplay(chatId, messageId, destPath, MediaPriority.DEFAULT) + ): Outcome = downloadMessageDisplay(chatId, messageId, destPath, MediaPriority.DEFAULT) override suspend fun downloadMessageDisplay( chatId: PeerId, @@ -138,7 +141,10 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { AppLog.api("media", "downloadCustomEmoji fail id=$documentId") return connected } - is Outcome.Ok -> Unit + + is Outcome.Ok -> { + Unit + } } return retryShortFlood { try { @@ -155,15 +161,34 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { } } + override suspend fun downloadMessageMediaChecked( + chatId: PeerId, + messageId: Int, + destPath: String, + expectedCacheKey: String, + kind: MediaFetchKind, + priority: Int, + offset: Long?, + ): Outcome { + require(offset == null || (offset >= 0 && offset % (512 * 1024) == 0L)) + val fileKind = when (kind) { + MediaFetchKind.Thumb -> TelegramFileKind.Thumb + MediaFetchKind.Display -> TelegramFileKind.Display + MediaFetchKind.Full -> TelegramFileKind.Full + } + return downloadTelegramFile( + chatId, messageId, destPath, fileKind, priority, expectedCacheKey, offset + ) + } + private enum class TelegramFileKind { Full, Thumb, Display } - private fun mediaDispatchClass(priority: Int): Int = - when { - MediaPriority.isBackground(priority) -> DispatchClass.BACKGROUND_MEDIA - priority >= MediaPriority.USER -> DispatchClass.USER_MEDIA - priority >= MediaPriority.VISIBLE -> DispatchClass.VISIBLE_MEDIA - else -> DispatchClass.ORDINARY_MEDIA - } + private fun mediaDispatchClass(priority: Int): Int = when { + MediaPriority.isBackground(priority) -> DispatchClass.BACKGROUND_MEDIA + priority >= MediaPriority.USER -> DispatchClass.USER_MEDIA + priority >= MediaPriority.VISIBLE -> DispatchClass.VISIBLE_MEDIA + else -> DispatchClass.ORDINARY_MEDIA + } private suspend fun downloadTelegramFile( chatId: PeerId, @@ -171,6 +196,8 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { destPath: String, kind: TelegramFileKind, priority: Int, + expectedCacheKey: String? = null, + offset: Long? = null, ): Outcome { AppLog.api( "download media", @@ -185,7 +212,9 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { return connected } - is Outcome.Ok -> Unit + is Outcome.Ok -> { + Unit + } } val dispatchClass = mediaDispatchClass(priority) return withContext(NativeRequestTag(destPath)) { @@ -196,32 +225,48 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { "download media", "start ${kind.name} chat=${chatId.value} id=$messageId pri=$priority", ) + suspend fun fetch(): String = core.onNativeClass(dispatchClass) { activeHandle -> - when (kind) { - TelegramFileKind.Thumb -> - core.native.downloadMessageThumb( - activeHandle, - chatId.value, - messageId, - destPath - ) + if (expectedCacheKey != null) { + core.native.downloadMessageMediaChecked( + activeHandle, + chatId.value, + messageId, + destPath, + expectedCacheKey, + kind.name.lowercase(), + offset, + ) + } else { + when (kind) { + TelegramFileKind.Thumb -> { + core.native.downloadMessageThumb( + activeHandle, + chatId.value, + messageId, + destPath, + ) + } - TelegramFileKind.Display -> - core.native.downloadMessageDisplay( - activeHandle, - chatId.value, - messageId, - destPath - ) + TelegramFileKind.Display -> { + core.native.downloadMessageDisplay( + activeHandle, + chatId.value, + messageId, + destPath, + ) + } - TelegramFileKind.Full -> - core.native.downloadMessageMedia( - activeHandle, - chatId.value, - messageId, - destPath - ) + TelegramFileKind.Full -> { + core.native.downloadMessageMedia( + activeHandle, + chatId.value, + messageId, + destPath, + ) + } + } } } @@ -252,7 +297,7 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { } if (kind == TelegramFileKind.Display && raw.contains( "no display size", - ignoreCase = true + ignoreCase = true, ) ) { return@retryShortFlood Outcome.Err("no display size") @@ -264,10 +309,10 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { val homeAlive = core.activeHandleOrZero().let { activeHandle -> activeHandle == 0L || runCatching { core.native.isAuthorized( - activeHandle + activeHandle, ) }.getOrDefault( - true + true, ) } if (!homeAlive) { @@ -280,11 +325,17 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { } } - override fun promoteDownload(destPath: String, priority: Int) { + override fun promoteDownload( + destPath: String, + priority: Int, + ) { promoteTaggedRequest(core.native, destPath, mediaDispatchClass(priority)) } - override fun peekMessageInlineThumb(chatId: PeerId, messageId: Int): ByteArray? { + override fun peekMessageInlineThumb( + chatId: PeerId, + messageId: Int, + ): ByteArray? { val handle = core.activeHandleOrZero() if (handle == 0L) return null return runCatching { diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/media/MediaOps.kt b/network/bridge/src/main/java/org/monogram/network/bridge/media/MediaOps.kt index e13a8b464..14e3b1d9e 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/media/MediaOps.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/media/MediaOps.kt @@ -4,18 +4,31 @@ import org.monogram.core.common.Outcome import org.monogram.core.models.PeerId import org.monogram.core.models.Wallpaper import org.monogram.core.models.WallpaperCatalog +import org.monogram.network.http.MediaFetchKind interface MediaOps { suspend fun getWallpapers(hash: Long = 0): Outcome = Outcome.Err("Wallpaper catalog unavailable") - suspend fun downloadWallpaper(wallpaper: Wallpaper, destPath: String): Outcome = - Outcome.Err("Wallpaper download unavailable") + suspend fun downloadWallpaper( + wallpaper: Wallpaper, + destPath: String, + ): Outcome = Outcome.Err("Wallpaper download unavailable") + + suspend fun downloadMessageMediaChecked( + chatId: PeerId, + messageId: Int, + destPath: String, + expectedCacheKey: String, + kind: MediaFetchKind, + priority: Int, + offset: Long? = null, + ): Outcome = Outcome.Err("Checked media download unavailable") suspend fun downloadMessageMedia( chatId: PeerId, messageId: Int, - destPath: String + destPath: String, ): Outcome suspend fun downloadMessageMedia( @@ -29,14 +42,13 @@ interface MediaOps { chatId: PeerId, messageId: Int, destPath: String, - offset: Long - ): Outcome = - Outcome.Err("Media streaming unavailable") + offset: Long, + ): Outcome = Outcome.Err("Media streaming unavailable") suspend fun downloadMessageThumb( chatId: PeerId, messageId: Int, - destPath: String + destPath: String, ): Outcome suspend fun downloadMessageThumb( @@ -49,9 +61,8 @@ interface MediaOps { suspend fun downloadMessageDisplay( chatId: PeerId, messageId: Int, - destPath: String - ): Outcome = - downloadMessageMedia(chatId, messageId, destPath) + destPath: String, + ): Outcome = downloadMessageMedia(chatId, messageId, destPath) suspend fun downloadMessageDisplay( chatId: PeerId, @@ -60,10 +71,15 @@ interface MediaOps { priority: Int, ): Outcome = downloadMessageDisplay(chatId, messageId, destPath) - fun promoteDownload(destPath: String, priority: Int) = Unit + fun promoteDownload( + destPath: String, + priority: Int, + ) = Unit - suspend fun downloadCustomEmoji(documentId: Long, destPath: String): Outcome = - Outcome.Err("unsupported") + suspend fun downloadCustomEmoji( + documentId: Long, + destPath: String, + ): Outcome = Outcome.Err("unsupported") suspend fun downloadCustomEmoji( documentId: Long, @@ -71,7 +87,10 @@ interface MediaOps { priority: Int, ): Outcome = downloadCustomEmoji(documentId, destPath) - fun peekMessageInlineThumb(chatId: PeerId, messageId: Int): ByteArray? = null + fun peekMessageInlineThumb( + chatId: PeerId, + messageId: Int, + ): ByteArray? = null fun peekPeerInlineThumb(peerId: PeerId): ByteArray? = null } diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageApi.kt b/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageApi.kt index 0fe651591..202f099e8 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageApi.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageApi.kt @@ -39,17 +39,24 @@ internal class MessageApi( private val core: SessionCore, private val profile: ProfileOps, ) : MessageOps { - override suspend fun getHistory(chatId: PeerId, limit: Int): Outcome> { + override suspend fun getHistory( + chatId: PeerId, + limit: Int, + ): Outcome> { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected is Outcome.Ok -> Unit } val historyAt = if (PerfLog.isEnabled()) System.nanoTime() else 0L - val outcome = withTimeoutOrNull(core.historyTimeoutMs.milliseconds) { - core.rpc("getHistory failed") { activeHandle -> - core.native.getHistory(activeHandle, chatId.value, limit).map { it.toModel() } - } - } ?: core.fail(Exception("RPC timeout"), "getHistory failed") + val outcome = + withTimeoutOrNull(core.historyTimeoutMs.milliseconds) { + core.coalesce("getHistory:${chatId.value}:$limit") { + core.rpc("getHistory failed") { activeHandle -> + core.native.getHistory(activeHandle, chatId.value, limit) + .map { it.toModel() } + } + } + } ?: core.fail(Exception("RPC timeout"), "getHistory failed") if (historyAt != 0L) { PerfLog.trace( op = "getHistory", @@ -63,7 +70,10 @@ internal class MessageApi( return outcome } - override suspend fun getMessage(chatId: PeerId, messageId: Int): Outcome { + override suspend fun getMessage( + chatId: PeerId, + messageId: Int, + ): Outcome { if (messageId <= 0) return Outcome.Err("message id required") when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected @@ -116,19 +126,24 @@ internal class MessageApi( is Outcome.Ok -> Unit } val call: suspend (Long) -> List = { activeHandle -> - core.native.getHistoryPage( - activeHandle, - chatId.value, - limit, - offsetId, - offsetDate, - addOffset, - ).map { it.toModel() } - } - return if (background) { - core.rpcBackground("getHistoryPage failed", call) - } else { - core.rpc("getHistoryPage failed", call) + core.native + .getHistoryPage( + activeHandle, + chatId.value, + limit, + offsetId, + offsetDate, + addOffset, + ).map { it.toModel() } + } + return core.coalesce( + "getHistoryPage:${chatId.value}:$limit:$offsetId:$offsetDate:$addOffset:$background", + ) { + if (background) { + core.rpcBackground("getHistoryPage failed", call) + } else { + core.rpc("getHistoryPage failed", call) + } } } @@ -142,7 +157,8 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpc("searchMessages failed") { activeHandle -> - core.native.searchMessages(activeHandle, chatId.value, query, limit) + core.native + .searchMessages(activeHandle, chatId.value, query, limit) .map { it.toModel() } } } @@ -160,15 +176,16 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcBackground("searchMessagesFiltered failed") { activeHandle -> - core.native.searchMessagesFiltered( - activeHandle, - chatId.value, - query, - filter, - offsetId, - addOffset, - limit, - ).map { it.toModel() } + core.native + .searchMessagesFiltered( + activeHandle, + chatId.value, + query, + filter, + offsetId, + addOffset, + limit, + ).map { it.toModel() } } } @@ -185,15 +202,16 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcBackground("searchMessagesPage failed") { activeHandle -> - core.native.searchMessagesPage( - activeHandle, - chatId.value, - query, - filter, - offsetId, - addOffset, - limit, - ).toModel() + core.native + .searchMessagesPage( + activeHandle, + chatId.value, + query, + filter, + offsetId, + addOffset, + limit, + ).toModel() } } @@ -220,29 +238,33 @@ internal class MessageApi( is Outcome.Err -> return connected is Outcome.Ok -> Unit } - val dto = core.rpcBackground("getMessageReadParticipants failed") { activeHandle -> - core.native.getMessageReadParticipants(activeHandle, chatId.value, messageId) - } - val viewers = when (dto) { - is Outcome.Err -> return dto - is Outcome.Ok -> readParticipantsOutcome(dto.value, played) - } + val dto = + core.rpcBackground("getMessageReadParticipants failed") { activeHandle -> + core.native.getMessageReadParticipants(activeHandle, chatId.value, messageId) + } + val viewers = + when (dto) { + is Outcome.Err -> return dto + is Outcome.Ok -> readParticipantsOutcome(dto.value, played) + } if (viewers !is MessageViewers.Ready || viewers.viewers.isEmpty()) return Outcome.Ok(viewers) // Names come from one member page: read receipts only exist for groups // at or below the threshold, so a single page covers every reader. - val members = when ( - val page = profile.getProfileMembers(chatId, limit = memberScanLimit.coerceIn(1, 200)) - ) { - is Outcome.Ok -> page.value.members - is Outcome.Err -> emptyList() - } + val members = + when ( + val page = + profile.getProfileMembers(chatId, limit = memberScanLimit.coerceIn(1, 200)) + ) { + is Outcome.Ok -> page.value.members + is Outcome.Err -> emptyList() + } return Outcome.Ok(viewers.copy(viewers = attachViewerProfiles(viewers.viewers, members))) } override suspend fun getReactionUsersPage( chatId: PeerId, messageId: Int, - offset: String? + offset: String?, ): Outcome { if (messageId <= 0) return Outcome.Err("message id required") when (val connected = core.ensureConnected()) { @@ -252,15 +274,17 @@ internal class MessageApi( return core.rpc("getMessageReactionsPage failed") { handle -> val page = core.native.getMessageReactionsPage(handle, chatId.value, messageId, offset) MessageViewerPage( - page.peers.map { it.toViewer() }, page.count, - page.nextOffset?.takeIf { it.isNotEmpty() }) + page.peers.map { it.toViewer() }, + page.count, + page.nextOffset?.takeIf { it.isNotEmpty() }, + ) } } override suspend fun getPollVotersPage( chatId: PeerId, messageId: Int, - offset: String? + offset: String?, ): Outcome { if (messageId <= 0) return Outcome.Err("message id required") when (val connected = core.ensureConnected()) { @@ -270,8 +294,10 @@ internal class MessageApi( return core.rpc("getPollVotesPage failed") { handle -> val page = core.native.getPollVotesPage(handle, chatId.value, messageId, offset) MessageViewerPage( - page.voters.map { it.toViewer() }, page.count, - page.nextOffset?.takeIf { it.isNotEmpty() }) + page.voters.map { it.toViewer() }, + page.count, + page.nextOffset?.takeIf { it.isNotEmpty() }, + ) } } @@ -284,11 +310,13 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpc("getMessageReactionsList failed") { activeHandle -> - core.native.getMessageReactionsList( - activeHandle, - chatId.value, - messageId - ).peers.map { row -> row.toViewer() } + core.native + .getMessageReactionsList( + activeHandle, + chatId.value, + messageId, + ).peers + .map { row -> row.toViewer() } } } @@ -316,7 +344,8 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcBackground("getOutboxReadDate failed") { activeHandle -> - core.native.getOutboxReadDate(activeHandle, chatId.value, messageId) + core.native + .getOutboxReadDate(activeHandle, chatId.value, messageId) .let(::outboxReadOutcome) } } @@ -343,7 +372,7 @@ internal class MessageApi( override suspend fun getScheduledMessages( chatId: PeerId, - ids: List + ids: List, ): Outcome> { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected @@ -354,7 +383,10 @@ internal class MessageApi( } } - override suspend fun sendScheduledMessages(chatId: PeerId, ids: List): Outcome { + override suspend fun sendScheduledMessages( + chatId: PeerId, + ids: List, + ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected is Outcome.Ok -> Unit @@ -364,7 +396,10 @@ internal class MessageApi( } } - override suspend fun deleteScheduledMessages(chatId: PeerId, ids: List): Outcome { + override suspend fun deleteScheduledMessages( + chatId: PeerId, + ids: List, + ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected is Outcome.Ok -> Unit @@ -375,8 +410,12 @@ internal class MessageApi( } override suspend fun editScheduledMessage( - chatId: PeerId, messageId: Int, scheduleDate: Int, - text: String?, entitiesJson: String?, noWebpage: Boolean, + chatId: PeerId, + messageId: Int, + scheduleDate: Int, + text: String?, + entitiesJson: String?, + noWebpage: Boolean, ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected @@ -390,7 +429,7 @@ internal class MessageApi( scheduleDate, text, entitiesJson, - noWebpage + noWebpage, ) } } @@ -421,58 +460,79 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcWrite("importContacts failed") { handle -> - val result = core.native.importContacts(handle, contacts.map { - uniffi.monogram_mtproto.ContactImportSpec( - it.clientId, - it.phone, - it.firstName, - it.lastName + val result = + core.native.importContacts( + handle, + contacts.map { + uniffi.monogram_mtproto.ContactImportSpec( + it.clientId, + it.phone, + it.firstName, + it.lastName, + ) + }, ) - }) ContactImportResult( result.imported.map { ContactImportMatch( it.clientId, - PeerId(it.userId) + PeerId(it.userId), ) }, result.retryClientIds, result.popular.map { ContactImportPopularity( it.clientId, - it.importers + it.importers, ) - }) + }, + ) } } override suspend fun sendLocationWithOptions( - chatId: PeerId, place: GeoPlace, randomId: Long, - options: MessageSendOptions, replyToMsgId: Int, topMsgId: Int + chatId: PeerId, + place: GeoPlace, + randomId: Long, + options: MessageSendOptions, + replyToMsgId: Int, + topMsgId: Int, ): Outcome { - if (randomId == 0L || (!place.live && place.periodSeconds != 0) || (place.live && place.periodSeconds <= 0)) return Outcome.Err( - "location send identity or period invalid" - ) + if (randomId == 0L || (!place.live && place.periodSeconds != 0) || (place.live && place.periodSeconds <= 0)) { + return Outcome.Err( + "location send identity or period invalid", + ) + } when (val connected = core.ensureConnected()) { - is Outcome.Err -> return connected; is Outcome.Ok -> Unit + is Outcome.Err -> return connected + is Outcome.Ok -> Unit } return core.rpcWrite("sendLocationWithOptions failed") { handle -> - core.native.sendLocationWithOptions( - handle, chatId.value, - uniffi.monogram_mtproto.LocationSendSpec( - place.latitude, - place.longitude, - if (place.live) place.periodSeconds else 0, - place.heading - ), - replyToMsgId, topMsgId, randomId, options.toNative() - ).toModel() + core.native + .sendLocationWithOptions( + handle, + chatId.value, + uniffi.monogram_mtproto.LocationSendSpec( + place.latitude, + place.longitude, + if (place.live) place.periodSeconds else 0, + place.heading, + ), + replyToMsgId, + topMsgId, + randomId, + options.toNative(), + ).toModel() } } override suspend fun sendContactWithOptions( - chatId: PeerId, spec: ContactSendSpec, randomId: Long, - options: MessageSendOptions, replyToMsgId: Int, topMsgId: Int, + chatId: PeerId, + spec: ContactSendSpec, + randomId: Long, + options: MessageSendOptions, + replyToMsgId: Int, + topMsgId: Int, ): Outcome { if (randomId == 0L) return Outcome.Err("contact send requires persisted identity") when (val connected = core.ensureConnected()) { @@ -480,22 +540,31 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcWrite("sendContactWithOptions failed") { handle -> - core.native.sendContactWithOptions( - handle, chatId.value, - uniffi.monogram_mtproto.ContactSendSpec( - spec.phone, - spec.firstName, - spec.lastName, - spec.vcard - ), - replyToMsgId, topMsgId, randomId, options.toNative() - ).toModel() + core.native + .sendContactWithOptions( + handle, + chatId.value, + uniffi.monogram_mtproto.ContactSendSpec( + spec.phone, + spec.firstName, + spec.lastName, + spec.vcard, + ), + replyToMsgId, + topMsgId, + randomId, + options.toNative(), + ).toModel() } } override suspend fun sendChecklistWithOptions( - chatId: PeerId, spec: ChecklistSendSpec, randomId: Long, - options: MessageSendOptions, replyToMsgId: Int, topMsgId: Int, + chatId: PeerId, + spec: ChecklistSendSpec, + randomId: Long, + options: MessageSendOptions, + replyToMsgId: Int, + topMsgId: Int, ): Outcome { if (randomId == 0L) return Outcome.Err("checklist send requires persisted identity") when (val connected = core.ensureConnected()) { @@ -503,22 +572,31 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcWrite("sendChecklistWithOptions failed") { handle -> - core.native.sendChecklistWithOptions( - handle, chatId.value, - uniffi.monogram_mtproto.ChecklistSendSpec( - spec.title, - spec.items, - spec.othersCanAppend, - spec.othersCanComplete - ), - replyToMsgId, topMsgId, randomId, options.toNative() - ).toModel() + core.native + .sendChecklistWithOptions( + handle, + chatId.value, + uniffi.monogram_mtproto.ChecklistSendSpec( + spec.title, + spec.items, + spec.othersCanAppend, + spec.othersCanComplete, + ), + replyToMsgId, + topMsgId, + randomId, + options.toNative(), + ).toModel() } } override suspend fun sendPollWithOptions( - chatId: PeerId, spec: PollSendSpec, randomId: Long, - options: MessageSendOptions, replyToMsgId: Int, topMsgId: Int, + chatId: PeerId, + spec: PollSendSpec, + randomId: Long, + options: MessageSendOptions, + replyToMsgId: Int, + topMsgId: Int, ): Outcome { if (randomId == 0L || spec.correctAnswer?.let { it < 0 } == true) return Outcome.Err("poll send identity or answer invalid") when (val connected = core.ensureConnected()) { @@ -526,20 +604,34 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcWrite("sendPollWithOptions failed") { handle -> - core.native.sendPollWithOptions( - handle, chatId.value, - uniffi.monogram_mtproto.PollSendSpec( - spec.question, spec.answers, spec.anonymous, spec.multipleChoice, - spec.quiz, spec.correctAnswer?.toUInt(), spec.solution - ), - replyToMsgId, topMsgId, randomId, options.toNative() - ).toModel() + core.native + .sendPollWithOptions( + handle, + chatId.value, + uniffi.monogram_mtproto.PollSendSpec( + spec.question, + spec.answers, + spec.anonymous, + spec.multipleChoice, + spec.quiz, + spec.correctAnswer?.toUInt(), + spec.solution, + ), + replyToMsgId, + topMsgId, + randomId, + options.toNative(), + ).toModel() } } override suspend fun sendDiceWithOptions( - chatId: PeerId, emoticon: String, randomId: Long, - options: MessageSendOptions, replyToMsgId: Int, topMsgId: Int, + chatId: PeerId, + emoticon: String, + randomId: Long, + options: MessageSendOptions, + replyToMsgId: Int, + topMsgId: Int, ): Outcome { if (randomId == 0L) return Outcome.Err("dice send requires persisted identity") when (val connected = core.ensureConnected()) { @@ -547,15 +639,16 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcWrite("sendDiceWithOptions failed") { handle -> - core.native.sendDiceWithOptions( - handle, - chatId.value, - emoticon, - replyToMsgId, - topMsgId, - randomId, - options.toNative() - ).toModel() + core.native + .sendDiceWithOptions( + handle, + chatId.value, + emoticon, + replyToMsgId, + topMsgId, + randomId, + options.toNative(), + ).toModel() } } @@ -574,10 +667,18 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcWrite("sendTextWithOptions failed") { handle -> - core.native.sendTextMessageWithOptions( - handle, chatId.value, text, replyToMsgId, - entitiesJson, topMsgId, webpageUrl, randomId, options.toNative() - ).toModel() + core.native + .sendTextMessageWithOptions( + handle, + chatId.value, + text, + replyToMsgId, + entitiesJson, + topMsgId, + webpageUrl, + randomId, + options.toNative(), + ).toModel() } } @@ -602,20 +703,23 @@ internal class MessageApi( handle = core.activeHandleOrZero(), dispatchClass = DispatchClass.INTERACTIVE_WRITE, ) - val outcome = core.rpcWrite("sendText failed") { activeHandle -> - val sent = core.native.sendTextMessage( - activeHandle, - chatId.value, - text, - replyToMsgId, - entitiesJson, - topMsgId, - webpageUrl, - randomId, - ).toModel() - AppLog.api("sendText", "ok id=${sent.id.id}") - sent - } + val outcome = + core.rpcWrite("sendText failed") { activeHandle -> + val sent = + core.native + .sendTextMessage( + activeHandle, + chatId.value, + text, + replyToMsgId, + entitiesJson, + topMsgId, + webpageUrl, + randomId, + ).toModel() + AppLog.api("sendText", "ok id=${sent.id.id}") + sent + } if (sendAt != 0L) { PerfLog.trace( op = "sendText", @@ -643,15 +747,17 @@ internal class MessageApi( } AppLog.api("sendPhoto", "start") return core.rpcWrite("sendPhoto failed") { activeHandle -> - val sent = core.native.sendPhotoMessage( - activeHandle, - chatId.value, - path, - caption, - replyToMsgId, - topMsgId, - entitiesJson, - ).toModel() + val sent = + core.native + .sendPhotoMessage( + activeHandle, + chatId.value, + path, + caption, + replyToMsgId, + topMsgId, + entitiesJson, + ).toModel() AppLog.api("sendPhoto", "ok id=${sent.id.id}") sent } @@ -661,20 +767,21 @@ internal class MessageApi( chatId: PeerId, messageId: Int, expectedKey: String, - item: UploadItem + item: UploadItem, ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected is Outcome.Ok -> Unit } return core.rpcWrite("replaceUploadedMedia failed") { handle -> - core.native.replaceUploadedMedia( - handle, - chatId.value, - messageId, - expectedKey, - item.toDto() - ).toModel() + core.native + .replaceUploadedMedia( + handle, + chatId.value, + messageId, + expectedKey, + item.toDto(), + ).toModel() } } @@ -690,14 +797,15 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcWrite("sendUploadedMedia failed") { activeHandle -> - core.native.sendUploadedMedia( - activeHandle, - chatId.value, - item.toDto(), - replyToMsgId, - topMsgId, - entitiesJson ?: serialize(item.captionEntities), - ).toModel() + core.native + .sendUploadedMedia( + activeHandle, + chatId.value, + item.toDto(), + replyToMsgId, + topMsgId, + entitiesJson ?: serialize(item.captionEntities), + ).toModel() } } @@ -712,29 +820,40 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcWrite("sendUploadedAlbum failed") { activeHandle -> - core.native.sendUploadedAlbum( - activeHandle, - chatId.value, - items.map { it.toDto() }, - replyToMsgId, - topMsgId, - ).map { it.toModel() } + core.native + .sendUploadedAlbum( + activeHandle, + chatId.value, + items.map { it.toDto() }, + replyToMsgId, + topMsgId, + ).map { it.toModel() } } } override suspend fun sendUploadedMediaWithOptions( - chatId: PeerId, item: UploadItem, options: MessageSendOptions, - replyToMsgId: Int, topMsgId: Int, entitiesJson: String?, + chatId: PeerId, + item: UploadItem, + options: MessageSendOptions, + replyToMsgId: Int, + topMsgId: Int, + entitiesJson: String?, ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected is Outcome.Ok -> Unit } return core.rpcWrite("sendUploadedMediaWithOptions failed") { activeHandle -> - core.native.sendUploadedMediaWithOptions( - activeHandle, chatId.value, item.toDto(), replyToMsgId, - topMsgId, entitiesJson ?: serialize(item.captionEntities), options.toNative() - ).toModel() + core.native + .sendUploadedMediaWithOptions( + activeHandle, + chatId.value, + item.toDto(), + replyToMsgId, + topMsgId, + entitiesJson ?: serialize(item.captionEntities), + options.toNative(), + ).toModel() } } @@ -750,10 +869,15 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcWrite("sendUploadedAlbumWithOptions failed") { activeHandle -> - core.native.sendUploadedAlbumWithOptions( - activeHandle, chatId.value, items.map { it.toDto() }, - replyToMsgId, topMsgId, options.toNative() - ).map { it.toModel() } + core.native + .sendUploadedAlbumWithOptions( + activeHandle, + chatId.value, + items.map { it.toDto() }, + replyToMsgId, + topMsgId, + options.toNative(), + ).map { it.toModel() } } } @@ -769,13 +893,15 @@ internal class MessageApi( } AppLog.api("editText", "start") return core.rpcWrite("editText failed") { activeHandle -> - val sent = core.native.editTextMessage( - activeHandle, - chatId.value, - messageId, - text, - entitiesJson, - ).toModel() + val sent = + core.native + .editTextMessage( + activeHandle, + chatId.value, + messageId, + text, + entitiesJson, + ).toModel() AppLog.api("editText", "ok id=${sent.id.id}") sent } @@ -785,12 +911,13 @@ internal class MessageApi( fromChatId: PeerId, messageId: Int, toChatId: PeerId, - ): Outcome> = forwardMessages( - fromChatId = fromChatId, - messageIds = listOf(messageId), - toChatId = toChatId, - dropAuthor = false, - ) + ): Outcome> = + forwardMessages( + fromChatId = fromChatId, + messageIds = listOf(messageId), + toChatId = toChatId, + dropAuthor = false, + ) override suspend fun forwardMessages( fromChatId: PeerId, @@ -804,13 +931,15 @@ internal class MessageApi( } AppLog.api("forwardMessages", "start count=${messageIds.size} dropAuthor=$dropAuthor") return core.rpcWrite("forwardMessages failed") { activeHandle -> - val sent = core.native.forwardMessages( - activeHandle, - fromChatId.value, - messageIds, - toChatId.value, - dropAuthor, - ).map { it.toModel() } + val sent = + core.native + .forwardMessages( + activeHandle, + fromChatId.value, + messageIds, + toChatId.value, + dropAuthor, + ).map { it.toModel() } AppLog.api("forwardMessages", "ok count=${sent.size}") sent } @@ -848,7 +977,7 @@ internal class MessageApi( chatId.value, messageId, completed, - incompleted + incompleted, ) } } @@ -859,11 +988,12 @@ internal class MessageApi( latitude: Double?, longitude: Double?, heading: Int?, - stopped: Boolean + stopped: Boolean, ): Outcome { if (messageId <= 0) return Outcome.Err("live location message required") when (val connected = core.ensureConnected()) { - is Outcome.Err -> return connected; is Outcome.Ok -> Unit + is Outcome.Err -> return connected + is Outcome.Ok -> Unit } return core.rpcWrite("editLiveLocation failed") { handle -> core.native.editLiveLocation( @@ -873,7 +1003,7 @@ internal class MessageApi( latitude, longitude, heading, - stopped + stopped, ) } } @@ -906,21 +1036,25 @@ internal class MessageApi( override suspend fun removeChecklistItem( chatId: PeerId, messageId: Int, - edit: ChecklistTextEdit + edit: ChecklistTextEdit, ): Outcome { if (messageId <= 0 || edit.itemId == null) return Outcome.Err("checklist item required") when (val connected = core.ensureConnected()) { - is Outcome.Err -> return connected; is Outcome.Ok -> Unit + is Outcome.Err -> return connected + is Outcome.Ok -> Unit } return core.rpcWrite("removeChecklistItem failed") { handle -> core.native.removeChecklistItem( - handle, chatId.value, messageId, uniffi.monogram_mtproto.ChecklistTextEditDto( + handle, + chatId.value, + messageId, + uniffi.monogram_mtproto.ChecklistTextEditDto( edit.itemId, edit.text, serialize(edit.entities), edit.expectedText, - serialize(edit.expectedEntities) - ) + serialize(edit.expectedEntities), + ), ) } } @@ -928,7 +1062,7 @@ internal class MessageApi( override suspend fun editChecklistText( chatId: PeerId, messageId: Int, - edit: ChecklistTextEdit + edit: ChecklistTextEdit, ): Outcome { if (messageId <= 0 || edit.text.isBlank()) return Outcome.Err("checklist text required") when (val connected = core.ensureConnected()) { @@ -937,13 +1071,16 @@ internal class MessageApi( } return core.rpcWrite("editChecklistText failed") { handle -> core.native.editChecklistText( - handle, chatId.value, messageId, uniffi.monogram_mtproto.ChecklistTextEditDto( + handle, + chatId.value, + messageId, + uniffi.monogram_mtproto.ChecklistTextEditDto( itemId = edit.itemId, text = edit.text, entitiesJson = serialize(edit.entities), expectedText = edit.expectedText, - expectedEntitiesJson = serialize(edit.expectedEntities) - ) + expectedEntitiesJson = serialize(edit.expectedEntities), + ), ) } } @@ -951,7 +1088,7 @@ internal class MessageApi( override suspend fun editChecklistTitle( chatId: PeerId, messageId: Int, - title: String + title: String, ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected @@ -962,7 +1099,10 @@ internal class MessageApi( } } - override suspend fun closePoll(chatId: PeerId, messageId: Int): Outcome { + override suspend fun closePoll( + chatId: PeerId, + messageId: Int, + ): Outcome { if (messageId <= 0) return Outcome.Err("message id required") when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected @@ -972,7 +1112,7 @@ internal class MessageApi( core.native.closePoll( handle, chatId.value, - messageId + messageId, ) } } @@ -1033,7 +1173,8 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpcWrite("sendSavedGif failed") { activeHandle -> - core.native.sendSavedGif(activeHandle, chatId.value, documentId, replyToMsgId, topMsgId) + core.native + .sendSavedGif(activeHandle, chatId.value, documentId, replyToMsgId, topMsgId) .toModel() } } @@ -1082,7 +1223,8 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpc("getReplies failed") { activeHandle -> - core.native.getReplies(activeHandle, chatId.value, msgId, limit, offsetId, addOffset) + core.native + .getReplies(activeHandle, chatId.value, msgId, limit, offsetId, addOffset) .map { it.toModel() } } } @@ -1114,29 +1256,34 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpc("getUnreadMentions failed") { activeHandle -> - core.native.getUnreadMentions( - activeHandle, - chatId.value, - offsetId, - addOffset, - limit.coerceIn(1, 100), - topMsgId, - ).map { it.toModel() } + core.native + .getUnreadMentions( + activeHandle, + chatId.value, + offsetId, + addOffset, + limit.coerceIn(1, 100), + topMsgId, + ).map { it.toModel() } } } - override suspend fun readMentions(chatId: PeerId, topMsgId: Int): Outcome { + override suspend fun readMentions( + chatId: PeerId, + topMsgId: Int, + ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected is Outcome.Ok -> Unit } - return core.rpc("readMentions failed") { activeHandle -> - core.native.readMentions(activeHandle, chatId.value, topMsgId) - }.also { result -> - if (result is Outcome.Ok && topMsgId <= 0) { - core.localUpdates.emit(MtprotoUpdate.UnreadMentions(chatId, stillUnread = 0)) + return core + .rpc("readMentions failed") { activeHandle -> + core.native.readMentions(activeHandle, chatId.value, topMsgId) + }.also { result -> + if (result is Outcome.Ok && topMsgId <= 0) { + core.localUpdates.emit(MtprotoUpdate.UnreadMentions(chatId, stillUnread = 0)) + } } - } } override suspend fun readMessageContents( @@ -1166,28 +1313,33 @@ internal class MessageApi( is Outcome.Ok -> Unit } return core.rpc("getUnreadReactions failed") { activeHandle -> - core.native.getUnreadReactions( - activeHandle, - chatId.value, - offsetId, - addOffset, - limit.coerceIn(1, 100), - topMsgId, - ).map { it.toModel() } + core.native + .getUnreadReactions( + activeHandle, + chatId.value, + offsetId, + addOffset, + limit.coerceIn(1, 100), + topMsgId, + ).map { it.toModel() } } } - override suspend fun readReactions(chatId: PeerId, topMsgId: Int): Outcome { + override suspend fun readReactions( + chatId: PeerId, + topMsgId: Int, + ): Outcome { when (val connected = core.ensureConnected()) { is Outcome.Err -> return connected is Outcome.Ok -> Unit } - return core.rpc("readReactions failed") { activeHandle -> - core.native.readReactions(activeHandle, chatId.value, topMsgId) - }.also { result -> - if (result is Outcome.Ok && topMsgId <= 0) { - core.localUpdates.emit(MtprotoUpdate.UnreadReactions(chatId, stillUnread = 0)) + return core + .rpc("readReactions failed") { activeHandle -> + core.native.readReactions(activeHandle, chatId.value, topMsgId) + }.also { result -> + if (result is Outcome.Ok && topMsgId <= 0) { + core.localUpdates.emit(MtprotoUpdate.UnreadReactions(chatId, stillUnread = 0)) + } } - } } } diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/profile/Mappings.kt b/network/bridge/src/main/java/org/monogram/network/bridge/profile/Mappings.kt index b96c787c9..3c3dbfc02 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/profile/Mappings.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/profile/Mappings.kt @@ -29,6 +29,7 @@ internal fun ProfileDto.toModel(): Profile { username = username, about = about, avatarCacheKey = avatarCacheKey, + avatarKnown = avatarKnown, isSelf = isSelf, status = status, statusAt = statusAt, diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/session/DcTxtBootstrap.kt b/network/bridge/src/main/java/org/monogram/network/bridge/session/DcTxtBootstrap.kt index eb83155b9..c636bde45 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/session/DcTxtBootstrap.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/session/DcTxtBootstrap.kt @@ -15,7 +15,14 @@ internal object DcTxtBootstrap { "https://mozilla.cloudflare-dns.com/dns-query?name=apv3.stel.com&type=TXT" internal const val DOH_TTL_MS = 60L * 60L * 1000L - fun refreshSidecar(sessionPath: String) { + fun refreshSidecar(sessionPath: String) = refreshSidecar(sessionPath) { + URL(it).openConnection() as HttpURLConnection + } + + internal fun refreshSidecar( + sessionPath: String, + openConnection: (String) -> HttpURLConnection, + ) { val dir = File(sessionPath).parent ?: "." val file = File(dir, "dc_txt.parts") val now = System.currentTimeMillis() @@ -23,7 +30,8 @@ internal object DcTxtBootstrap { AppLog.api("dns txt", "parts=cached sidecar=${file.name}") return } - val parts = fetchParts(GOOGLE) ?: fetchParts(MOZILLA) ?: return + val parts = + fetchParts(GOOGLE, openConnection) ?: fetchParts(MOZILLA, openConnection) ?: return if (parts.size < 2) return runCatching { file.writeText(parts.take(2).joinToString("\n")) @@ -53,8 +61,11 @@ internal object DcTxtBootstrap { } } - private fun fetchParts(url: String): List? = runCatching { - val conn = (URL(url).openConnection() as HttpURLConnection).apply { + private fun fetchParts( + url: String, + openConnection: (String) -> HttpURLConnection, + ): List? = runCatching { + val conn = openConnection(url).apply { connectTimeout = 8_000 readTimeout = 8_000 setRequestProperty("Accept", "application/dns-json") diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/session/NativeWorkerRegistry.kt b/network/bridge/src/main/java/org/monogram/network/bridge/session/NativeWorkerRegistry.kt new file mode 100644 index 000000000..6d133299d --- /dev/null +++ b/network/bridge/src/main/java/org/monogram/network/bridge/session/NativeWorkerRegistry.kt @@ -0,0 +1,119 @@ +package org.monogram.network.bridge.session + +import kotlinx.coroutines.CompletableDeferred + +/** + * Register before starting any native work. Complete only after that work cannot touch native + * state again, including when starting the worker fails. Cancellation is only a signal. + * Cleanup can run on the completing or signalling thread and must not wait for this drain. + */ +internal class NativeWorkerRegistry { + private val lock = Any() + private val workers = mutableSetOf() + private val drained = CompletableDeferred() + private var closed = false + private var cleanupFailure: Throwable? = null + + fun tryRegister(cancel: () -> Unit, cleanup: () -> Unit): Worker? = synchronized(lock) { + if (closed) null else Worker(cancel, cleanup).also { workers.add(it) } + } + + fun close() { + val toCancel = synchronized(lock) { + if (closed) return + closed = true + workers.filter { !it.finishing && !it.signalled }.onEach { + it.signalled = true + it.signalling = true + } + } + if (toCancel.isEmpty()) completeIfDrained() + var failure: Throwable? = null + for (worker in toCancel) { + try { + worker.signal() + } catch (error: Throwable) { + if (failure == null) failure = + error else if (failure !== error) failure.addSuppressed(error) + } finally { + try { + signalFinished(worker) + } catch (error: Throwable) { + if (failure == null) failure = + error else if (failure !== error) failure.addSuppressed(error) + } + } + } + failure?.let { throw it } + } + + suspend fun awaitClosed() = drained.await() + + inner class Worker internal constructor( + internal val signal: () -> Unit, + private val cleanup: () -> Unit, + ) { + internal var finishing = false + internal var signalled = false + internal var signalling = false + + fun cancel() { + synchronized(lock) { + if (finishing || signalled) return + signalled = true + signalling = true + } + try { + signal() + } finally { + signalFinished(this) + } + } + + /** Call after the worker has really stopped, including on startup failure. */ + fun complete() { + val runCleanup = synchronized(lock) { + if (finishing) return + finishing = true + !signalling + } + if (runCleanup) cleanupAndRemove() + } + + internal fun cleanupAndRemove() { + var failure: Throwable? = null + try { + cleanup() + } catch (error: Throwable) { + failure = error + throw error + } finally { + synchronized(lock) { + if (failure != null) { + val previous = cleanupFailure + if (previous == null) cleanupFailure = failure + else if (previous !== failure) previous.addSuppressed(failure) + } + workers.remove(this) + } + completeIfDrained() + } + } + } + + private fun signalFinished(worker: Worker) { + val runCleanup = synchronized(lock) { + worker.signalling = false + worker.finishing + } + if (runCleanup) worker.cleanupAndRemove() + } + + private fun completeIfDrained() { + val state = synchronized(lock) { (closed && workers.isEmpty()) to cleanupFailure } + if (state.first) { + val failure = state.second + if (failure == null) drained.complete(Unit) else drained.completeExceptionally(failure) + } + } +} diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt index ba7ac6dd8..ffe5a3a0b 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt @@ -77,24 +77,28 @@ internal class SessionCore( override suspend fun finishPasskeyLogin( options: PasskeyLoginOptions, credential: PasskeyCredential, - ): Outcome = try { - Outcome.Ok(onNative { handle -> - val result = native.finishPasskeyLogin( - handle, - credential.toNative(), - options.dcId, - options.authKeyId + ): Outcome = + try { + Outcome.Ok( + onNative { handle -> + val result = + native.finishPasskeyLogin( + handle, + credential.toNative(), + options.dcId, + options.authKeyId, + ) + maybeStartUpdates(handle) + AuthState.Authorized(AuthSession(PeerId(result.userId), result.dcId)) + }, ) - maybeStartUpdates(handle) - AuthState.Authorized(AuthSession(PeerId(result.userId), result.dcId)) - }) - } catch (_: MtprotoException.PasswordRequired) { - Outcome.Ok(AuthState.AwaitingPassword) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - fail(e, "finishPasskeyLogin failed", degradeHome = false) - } + } catch (_: MtprotoException.PasswordRequired) { + Outcome.Ok(AuthState.AwaitingPassword) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + fail(e, "finishPasskeyLogin failed", degradeHome = false) + } override suspend fun initPasskeyRegistration(): Outcome { when (val result = ensureConnected()) { @@ -110,10 +114,11 @@ internal class SessionCore( is Outcome.Ok -> Unit } return rpcWrite("registerPasskey failed") { - native.registerPasskey( - it, - credential.toNative() - ).toModel() + native + .registerPasskey( + it, + credential.toNative(), + ).toModel() } } @@ -140,22 +145,28 @@ internal class SessionCore( is Outcome.Ok -> Unit } return try { - Outcome.Ok(onNative { handle -> - when (val result = native.exportLoginToken(handle)) { - is QrLoginDto.Token -> QrLogin.Code( - "tg://login?token=" + Base64.UrlSafe - .withPadding(Base64.PaddingOption.ABSENT).encode(result.token), - result.expires, - ) + Outcome.Ok( + onNative { handle -> + when (val result = native.exportLoginToken(handle)) { + is QrLoginDto.Token -> { + QrLogin.Code( + "tg://login?token=" + + Base64.UrlSafe + .withPadding(Base64.PaddingOption.ABSENT) + .encode(result.token), + result.expires, + ) + } - is QrLoginDto.Authorized -> { - maybeStartUpdates(handle) - QrLogin.Authorized( - AuthSession(PeerId(result.session.userId), result.session.dcId), - ) + is QrLoginDto.Authorized -> { + maybeStartUpdates(handle) + QrLogin.Authorized( + AuthSession(PeerId(result.session.userId), result.session.dcId), + ) + } } - } - }) + }, + ) } catch (_: MtprotoException.PasswordRequired) { Outcome.Ok(QrLogin.AwaitingPassword) } catch (e: CancellationException) { @@ -204,7 +215,7 @@ internal class SessionCore( return rpcWrite("revokeDeviceSession failed") { handle -> native.revokeDeviceSession( handle, - hash + hash, ) } } @@ -220,22 +231,45 @@ internal class SessionCore( private var proxyState: ProxyConnectionStatus = ProxyConnectionStatus.Disabled @Volatile - private var updatesStartedHandle: Long = 0L + internal var updatesStartedHandle: Long = 0L + private set + + @Volatile + internal var updatesStartRetryHandle: Long = 0L + private set private val updatesStartMutex = Mutex() internal val connectMutex = Mutex() + + private data class BindRejection( + val handle: Long, + val atMs: Long, + val error: Outcome.Err, + ) + + @Volatile + private var bindRejection: BindRejection? = null private val inflightMutex = Mutex() - private class SharedRead(val deferred: Deferred<*>, var consumers: Int = 0) + private class SharedRead( + val deferred: Deferred<*>, + var consumers: Int = 0, + ) - private val inflight = HashMap() + private data class ReadKey( + val generation: Long, + val operation: String, + ) + + private var handleGeneration = 0L + private val inflight = HashMap() @Volatile internal var sessionDead: Boolean = false @Volatile internal var closed: Boolean = false - internal fun isAuthFallback(fallback: String): Boolean = - fallback.startsWith("auth.") + + internal fun isAuthFallback(fallback: String): Boolean = fallback.startsWith("auth.") internal fun deadSessionError(): Outcome.Err { val telegram = TelegramError.sessionInvalidated() @@ -244,7 +278,9 @@ internal class SessionCore( /** Covers handle create/destroy only. Never held across a native RPC. */ internal val handleLock = Any() - internal val userRpcs = java.util.concurrent.atomic.AtomicInteger(0) + internal val userRpcs = + java.util.concurrent.atomic + .AtomicInteger(0) /** Set by [hibernate] while requests are in flight; applied on the last completion. */ @Volatile @@ -262,7 +298,10 @@ internal class SessionCore( internal val replayPending = AtomicBoolean(true) internal val syncState = MutableStateFlow(MtprotoUpdate.SyncState(false)) - internal fun publishSyncState(expectedHandle: Long, syncing: Boolean) { + internal fun publishSyncState( + expectedHandle: Long, + syncing: Boolean, + ) { synchronized(handleLock) { if (!closed && !sessionDead && handle == expectedHandle) { syncState.value = MtprotoUpdate.SyncState(syncing) @@ -272,9 +311,10 @@ internal class SessionCore( /** Wakes the update loop when a handle is created, reconnected, or closed. */ internal val updatesWake = Channel(capacity = Channel.CONFLATED) - internal val updatesEvents = MutableSharedFlow( - extraBufferCapacity = 64, - ) + internal val updatesEvents = + MutableSharedFlow( + extraBufferCapacity = 64, + ) init { PerfLog.installNative( @@ -283,13 +323,15 @@ internal class SessionCore( ) } - internal fun activeHandleOrZero(): Long = synchronized(handleLock) { - if (closed) 0L else handle - } + internal fun activeHandleOrZero(): Long = + synchronized(handleLock) { + if (closed) 0L else handle + } - internal fun isCurrentHandle(activeHandle: Long): Boolean = synchronized(handleLock) { - !closed && activeHandle != 0L && handle == activeHandle - } + internal fun isCurrentHandle(activeHandle: Long): Boolean = + synchronized(handleLock) { + !closed && activeHandle != 0L && handle == activeHandle + } /** Captures the client generation before the blocking native request starts. */ internal suspend fun onNative(block: suspend (Long) -> T): T = @@ -299,12 +341,16 @@ internal class SessionCore( internal suspend fun onNativeMedia(block: suspend (Long) -> T): T = onNativeClass(DispatchClass.INTERACTIVE_MEDIA, block) - internal suspend fun onNativeClass(dispatchClass: Int, block: suspend (Long) -> T): T = + internal suspend fun onNativeClass( + dispatchClass: Int, + block: suspend (Long) -> T, + ): T = nativeRequest(native, nativeDispatcher, dispatchClass) { - val activeHandle = synchronized(handleLock) { - ensureHandleLocked() - handle - } + val activeHandle = + synchronized(handleLock) { + ensureHandleLocked() + handle + } userRpcs.incrementAndGet() try { block(activeHandle) @@ -320,12 +366,16 @@ internal class SessionCore( internal suspend fun onNativeIfFree(block: (Long) -> T): T? = onNativeIfFreeClass(DispatchClass.BACKGROUND_READ, block) - internal suspend fun onNativeIfFreeClass(dispatchClass: Int, block: (Long) -> T): T? = + internal suspend fun onNativeIfFreeClass( + dispatchClass: Int, + block: (Long) -> T, + ): T? = nativeRequest(native, nativeDispatcher, dispatchClass) { - val activeHandle = synchronized(handleLock) { - check(!closed) { "MTProto client is closed" } - handle - } + val activeHandle = + synchronized(handleLock) { + check(!closed) { "MTProto client is closed" } + handle + } if (activeHandle == 0L) return@nativeRequest null if (userRpcs.get() > 0) return@nativeRequest null block(activeHandle) @@ -336,12 +386,13 @@ internal class SessionCore( fallback: String, degradeHome: Boolean = true, ): Outcome.Err { - val raw = when (e) { - is MtprotoException.Message -> e.v1 - is MtprotoException.RegistrationRequired -> "PHONE_NUMBER_UNOCCUPIED" - is MtprotoException.PasswordRequired -> "SESSION_PASSWORD_NEEDED" - else -> e.message - }?.removePrefix("v1=")?.trim().orEmpty() + val raw = + when (e) { + is MtprotoException.Message -> e.v1 + is MtprotoException.RegistrationRequired -> "PHONE_NUMBER_UNOCCUPIED" + is MtprotoException.PasswordRequired -> "SESSION_PASSWORD_NEEDED" + else -> e.message + }?.removePrefix("v1=")?.trim().orEmpty() val telegram = TelegramError.parse(raw.ifBlank { fallback }, fallback) // Perf-gated raw failure text; the summary alone cannot tell faults apart. if (PerfLog.isEnabled()) { @@ -360,9 +411,7 @@ internal class SessionCore( errorKind = telegram.kind.name, ) if (degradeHome) { - if (telegram.kind == TelegramError.Kind.Network || - telegram.kind == TelegramError.Kind.Internal - ) { + if (telegram.kind == TelegramError.Kind.Network || telegram.kind == TelegramError.Kind.Internal) { connectedHandle = 0L } if (telegram.requiresReauth) { @@ -372,7 +421,10 @@ internal class SessionCore( return Outcome.Err(telegram.message, e, telegram) } - internal fun markSessionDegraded(telegram: TelegramError, raw: String = "") { + internal fun markSessionDegraded( + telegram: TelegramError, + raw: String = "", + ) { if (sessionDead) return sessionDead = true syncState.value = MtprotoUpdate.SyncState(false) @@ -381,34 +433,44 @@ internal class SessionCore( sessionLostFlow.tryEmit(Unit) } - internal suspend fun rpc(fallback: String, block: suspend (Long) -> T): Outcome = - rpc(fallback, DispatchClass.INTERACTIVE_READ, degradeHome = true, block) + internal suspend fun rpc( + fallback: String, + block: suspend (Long) -> T, + ): Outcome = rpc(fallback, DispatchClass.INTERACTIVE_READ, degradeHome = true, block) internal suspend fun rpcSoft( fallback: String, block: suspend (Long) -> T, - ): Outcome = - rpc(fallback, DispatchClass.INTERACTIVE_READ, degradeHome = false, block) + ): Outcome = rpc(fallback, DispatchClass.INTERACTIVE_READ, degradeHome = false, block) internal suspend fun rpcBackground( fallback: String, - block: suspend (Long) -> T - ): Outcome = - rpc(fallback, DispatchClass.BACKGROUND_READ, degradeHome = true, block) + block: suspend (Long) -> T, + ): Outcome = rpc(fallback, DispatchClass.BACKGROUND_READ, degradeHome = true, block) - internal suspend fun rpcWrite(fallback: String, block: suspend (Long) -> T): Outcome = - rpc(fallback, DispatchClass.INTERACTIVE_WRITE, degradeHome = true, block) + internal suspend fun rpcWrite( + fallback: String, + block: suspend (Long) -> T, + ): Outcome = rpc(fallback, DispatchClass.INTERACTIVE_WRITE, degradeHome = true, block) /** One native call per key while identical reads overlap. */ - internal suspend fun coalesce(key: String, block: suspend () -> T): T { - val shared = inflightMutex.withLock { - val current = inflight[key] - val entry = current?.takeUnless { it.deferred.isCompleted } - ?: SharedRead(scope.async(start = CoroutineStart.LAZY) { block() }) - .also { inflight[key] = it } - entry.consumers++ - entry - } + internal suspend fun coalesce( + key: String, + block: suspend () -> T, + ): T { + val readKey = synchronized(handleLock) { ReadKey(handleGeneration, key) } + val shared = + inflightMutex.withLock { + val current = inflight[readKey] + val entry = + current?.takeUnless { it.deferred.isCompleted } ?: SharedRead( + scope.async( + start = CoroutineStart.LAZY, + ) { block() }, + ).also { inflight[readKey] = it } + entry.consumers++ + entry + } try { @Suppress("UNCHECKED_CAST") return (shared.deferred as Deferred).await() @@ -417,7 +479,7 @@ internal class SessionCore( inflightMutex.withLock { shared.consumers-- if (shared.consumers == 0) { - if (inflight[key] === shared) inflight.remove(key) + if (inflight[readKey] === shared) inflight.remove(readKey) if (!shared.deferred.isCompleted) shared.deferred.cancel() } } @@ -430,21 +492,31 @@ internal class SessionCore( dispatchClass: Int, degradeHome: Boolean, block: suspend (Long) -> T, + ): Outcome = + rpcResult(fallback, degradeHome) { + onNativeClass(dispatchClass, block) + } + + internal suspend fun rpcResult( + fallback: String, + degradeHome: Boolean = true, + block: suspend () -> T, ): Outcome = retryShortFlood { + val requestGeneration = synchronized(handleLock) { handleGeneration } try { perfOp("bridge:${fallback.removeSuffix(" failed")}") { - Outcome.Ok(onNativeClass(dispatchClass, block)) + Outcome.Ok(block()) } } catch (e: CancellationException) { throw e } catch (e: Exception) { if (isStaleHandleFailure(e)) { AppLog.api("mtproto", "stale handle; recreating") - resetHandle() + resetHandle(requestGeneration) try { perfOp("bridge:${fallback.removeSuffix(" failed")}") { - Outcome.Ok(onNativeClass(dispatchClass, block)) + Outcome.Ok(block()) } } catch (retry: CancellationException) { throw retry @@ -485,11 +557,12 @@ internal class SessionCore( langCode = info.langCode, ) } - handle = native.createClient( - apiId = credentials.apiId, - apiHash = credentials.apiHash, - sessionPath = sessionPath, - ) + handle = + native.createClient( + apiId = credentials.apiId, + apiHash = credentials.apiHash, + sessionPath = sessionPath, + ) updatesWake.trySend(Unit) } } @@ -509,14 +582,19 @@ internal class SessionCore( } try { if (native.isAuthorized(activeHandle)) { - publishSyncState(activeHandle, true) native.startUpdates(activeHandle) native.updateLazySyncConfig( activeHandle, lazy = true, - exceptions = emptyList() + exceptions = emptyList(), ) - if (isCurrentHandle(activeHandle)) updatesStartedHandle = activeHandle + synchronized(handleLock) { + if (isCurrentHandle(activeHandle)) { + publishSyncState(activeHandle, true) + updatesStartedHandle = activeHandle + updatesStartRetryHandle = 0L + } + } PerfLog.trace("updates", "start", handle = activeHandle) updatesWake.trySend(Unit) } @@ -525,6 +603,10 @@ internal class SessionCore( } catch (e: Exception) { val err = fail(e, "startUpdates failed", degradeHome = false) if (err.telegramError.requiresReauth) throw e + if (isCurrentHandle(activeHandle) && !sessionDead) { + updatesStartRetryHandle = activeHandle + updatesWake.trySend(Unit) + } } } } @@ -550,26 +632,48 @@ internal class SessionCore( connectMutex.withLock { if (sessionDead) return deadSessionError() val locked = if (closed) 0L else handle + bindRejection + ?.takeIf { + !closed && it.handle == locked && clock.elapsedMs() - it.atMs in 0 until 2_000L + }?.let { return it.error } if (locked != 0L && connectedHandle == locked) { maybeStartUpdates(locked) return Outcome.Ok(Unit) } AppLog.api("connect", "start handle=$locked") if (nativeProxyConfigured()) proxyState = ProxyConnectionStatus.Connecting - val connected = rpc("connect failed") { activeHandle -> - refreshDcSidecar(sessionPath) - try { - if (updatesStartedHandle == activeHandle) publishSyncState(activeHandle, true) - native.connect(activeHandle) - if (isCurrentHandle(activeHandle)) connectedHandle = activeHandle - } catch (e: Exception) { - connectedHandle = 0L - if (nativeProxyConfigured()) proxyState = ProxyConnectionStatus.Failed - throw e - } finally { - DcTxtBootstrap.logNativeStatus(sessionPath) + var rejectedBindingHandle = 0L + val connected = + rpc("connect failed") { activeHandle -> + refreshDcSidecar(sessionPath) + try { + if (updatesStartedHandle == activeHandle) publishSyncState( + activeHandle, + true + ) + native.connect(activeHandle) + if (isCurrentHandle(activeHandle)) connectedHandle = activeHandle + } catch (e: Exception) { + val raw = (e as? MtprotoException.Message)?.v1 ?: e.message + if (raw == "temp bind RPC 400: ENCRYPTED_MESSAGE_INVALID") { + rejectedBindingHandle = activeHandle + } + connectedHandle = 0L + if (nativeProxyConfigured()) proxyState = ProxyConnectionStatus.Failed + throw e + } finally { + DcTxtBootstrap.logNativeStatus(sessionPath) + } + activeHandle } - activeHandle + if (connected is Outcome.Err && rejectedBindingHandle != 0L && + isCurrentHandle( + rejectedBindingHandle, + ) + ) { + bindRejection = BindRejection(rejectedBindingHandle, clock.elapsedMs(), connected) + } else if (connected is Outcome.Ok) { + bindRejection = null } if (connected is Outcome.Ok) { if (nativeProxyConfigured()) proxyState = ProxyConnectionStatus.Connected @@ -601,19 +705,20 @@ internal class SessionCore( // auth entry point detects session_dead and creates a fresh auth key; // calling connect() first would return AUTH_KEY_UNREGISTERED and make // the login form appear permanently stuck. - val result = rpc("auth.sendCode failed") { activeHandle -> - val sent = native.sendAuthCode(activeHandle, phone) - AuthState.AwaitingCode( - phone = sent.phone, - phoneCodeHash = sent.phoneCodeHash, - codeType = sent.codeType, - codeLength = if (sent.codeLength > 0) sent.codeLength else 5, - ) - } + val result = + rpc("auth.sendCode failed") { activeHandle -> + val sent = native.sendAuthCode(activeHandle, phone) + AuthState.AwaitingCode( + phone = sent.phone, + phoneCodeHash = sent.phoneCodeHash, + codeType = sent.codeType, + codeLength = if (sent.codeLength > 0) sent.codeLength else 5, + ) + } if (result is Outcome.Ok) { AppLog.api( "auth.sendCode", - "ok type=${result.value.codeType} length=${result.value.codeLength}" + "ok type=${result.value.codeType} length=${result.value.codeLength}", ) } return result @@ -624,19 +729,20 @@ internal class SessionCore( phoneCodeHash: String, ): Outcome { AppLog.api("auth.resendCode", "start") - val result = rpc("auth.resendCode failed") { activeHandle -> - val sent = native.resendAuthCode(activeHandle, phone, phoneCodeHash) - AuthState.AwaitingCode( - phone = sent.phone, - phoneCodeHash = sent.phoneCodeHash, - codeType = sent.codeType, - codeLength = if (sent.codeLength > 0) sent.codeLength else 5, - ) - } + val result = + rpc("auth.resendCode failed") { activeHandle -> + val sent = native.resendAuthCode(activeHandle, phone, phoneCodeHash) + AuthState.AwaitingCode( + phone = sent.phone, + phoneCodeHash = sent.phoneCodeHash, + codeType = sent.codeType, + codeLength = if (sent.codeLength > 0) sent.codeLength else 5, + ) + } if (result is Outcome.Ok) { AppLog.api( "auth.resendCode", - "ok type=${result.value.codeType} length=${result.value.codeLength}" + "ok type=${result.value.codeType} length=${result.value.codeLength}", ) } return result @@ -654,11 +760,12 @@ internal class SessionCore( is Outcome.Ok -> Unit } return try { - val signedIn = onNative { activeHandle -> - val result = native.signIn(activeHandle, phone, phoneCodeHash, code) - maybeStartUpdates(activeHandle) - result - } + val signedIn = + onNative { activeHandle -> + val result = native.signIn(activeHandle, phone, phoneCodeHash, code) + maybeStartUpdates(activeHandle) + result + } AppLog.api("auth.signIn", "ok") Outcome.Ok( AuthState.Authorized( @@ -698,31 +805,51 @@ internal class SessionCore( } override fun libraryVersion(): String = native.libraryVersion() + override fun apiLayer(): Int = runCatching { native.apiLayer() }.getOrDefault(0) - override fun applyDownloadConcurrency(lanes: Int, parts: Int) { - runCatching { native.setDownloadConcurrency(lanes, parts) } - .onSuccess { AppLog.api("download concurrency", "lanes=$lanes parts=$parts") } + + override fun applyDownloadConcurrency( + lanes: Int, + parts: Int, + ) { + runCatching { + native.setDownloadConcurrency( + lanes, + parts, + ) + }.onSuccess { AppLog.api("download concurrency", "lanes=$lanes parts=$parts") } .onFailure { AppLog.warn("download concurrency", "apply failed: ${it.message}") } } override fun setFilePartKib(kib: Int) { runCatching { native.setFilePartKib(kib) } - .onSuccess { AppLog.api("upload part", "kib=$kib") } - .onFailure { AppLog.warn("upload part", "apply failed: ${it.message}") } + .onSuccess { + AppLog.api( + "upload part", + "kib=$kib", + ) + }.onFailure { AppLog.warn("upload part", "apply failed: ${it.message}") } } override fun setDownloadChunkKib(kib: Int) { runCatching { native.setDownloadChunkKib(kib) } - .onSuccess { AppLog.api("download chunk", "kib=$kib") } - .onFailure { AppLog.warn("download chunk", "apply failed: ${it.message}") } + .onSuccess { + AppLog.api( + "download chunk", + "kib=$kib", + ) + }.onFailure { AppLog.warn("download chunk", "apply failed: ${it.message}") } } - override fun downloadChunkKib(): Int = - runCatching { native.downloadChunkKib() }.getOrDefault(0) + override fun downloadChunkKib(): Int = runCatching { native.downloadChunkKib() }.getOrDefault(0) override fun setDownloadProgressListener(listener: ((String, Long, Long) -> Unit)?) { - runCatching { native.setDownloadProgressListener(listener) } - .onFailure { AppLog.warn("download progress", "listener failed: ${it.message}") } + runCatching { native.setDownloadProgressListener(listener) }.onFailure { + AppLog.warn( + "download progress", + "listener failed: ${it.message}", + ) + } } override fun downloadConcurrency(): List = @@ -731,9 +858,10 @@ internal class SessionCore( override fun setDialogForeground(active: Boolean) { dialogForeground = active if (active) return - val activeHandle = synchronized(handleLock) { - if (closed || handle == 0L) 0L else handle - } + val activeHandle = + synchronized(handleLock) { + if (closed || handle == 0L) 0L else handle + } if (activeHandle == 0L) return scope.launch { try { @@ -754,19 +882,22 @@ internal class SessionCore( override suspend fun logout(): Outcome { sessionDead = false val activeHandle = activeHandleOrZero() - val remote = if (activeHandle == 0L) { - Outcome.Ok(Unit) - } else { - try { - Outcome.Ok(nativeRequest(native, nativeDispatcher, DispatchClass.INTERACTIVE_READ) { - native.logout(activeHandle) - }) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - fail(e, "auth.logOut failed") + val remote = + if (activeHandle == 0L) { + Outcome.Ok(Unit) + } else { + try { + Outcome.Ok( + nativeRequest(native, nativeDispatcher, DispatchClass.INTERACTIVE_READ) { + native.logout(activeHandle) + }, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + fail(e, "auth.logOut failed") + } } - } // Local teardown is mandatory even when the server already revoked the // key or the network is unavailable. The caller clears Room only after // this method returns. @@ -778,11 +909,12 @@ internal class SessionCore( if (tmp.exists()) tmp.delete() // A path with no extension, or one ending in a bare dot, is left unchanged. val lastDot = sessionPath.lastIndexOf('.') - val altTmpPath = if (lastDot in 0 until sessionPath.length - 1) { - sessionPath.substring(0, lastDot) + ".tmp" - } else { - sessionPath - } + val altTmpPath = + if (lastDot in 0 until sessionPath.length - 1) { + sessionPath.substring(0, lastDot) + ".tmp" + } else { + sessionPath + } val altTmp = java.io.File(altTmpPath) if (altTmp.exists() && altTmp.absolutePath != tmp.absolutePath) altTmp.delete() } @@ -817,7 +949,7 @@ internal class SessionCore( config.port, config.username, config.password, - config.secret.copyOf() + config.secret.copyOf(), ) proxyState = ProxyConnectionStatus.Disconnected } @@ -828,18 +960,19 @@ internal class SessionCore( } } - override fun setTransportMode(mode: MtprotoTransportMode): Outcome = try { - native.setTransportMode( - when (mode) { - MtprotoTransportMode.PADDED_INTERMEDIATE -> "padded_intermediate" - MtprotoTransportMode.HTTP -> "http" - }, - ) - resetHandle() - Outcome.Ok(Unit) - } catch (error: Throwable) { - Outcome.Err(error.message ?: "transport mode configuration failed", error) - } + override fun setTransportMode(mode: MtprotoTransportMode): Outcome = + try { + native.setTransportMode( + when (mode) { + MtprotoTransportMode.PADDED_INTERMEDIATE -> "padded_intermediate" + MtprotoTransportMode.HTTP -> "http" + }, + ) + resetHandle() + Outcome.Ok(Unit) + } catch (error: Throwable) { + Outcome.Err(error.message ?: "transport mode configuration failed", error) + } override suspend fun pingProxy(config: ProxyConfig): Outcome { when (val valid = config.validate()) { @@ -869,14 +1002,15 @@ internal class SessionCore( } } - override fun clearProxy(): Outcome = try { - native.clearProxy() - proxyState = ProxyConnectionStatus.Disabled - resetHandle() - Outcome.Ok(Unit) - } catch (error: Throwable) { - Outcome.Err(error.message ?: "proxy configuration failed", error) - } + override fun clearProxy(): Outcome = + try { + native.clearProxy() + proxyState = ProxyConnectionStatus.Disabled + resetHandle() + Outcome.Ok(Unit) + } catch (error: Throwable) { + Outcome.Err(error.message ?: "proxy configuration failed", error) + } override fun close() { synchronized(handleLock) { @@ -888,17 +1022,23 @@ internal class SessionCore( updatesWake.close() } - internal fun resetHandle() { - val oldHandle = synchronized(handleLock) { - handle.also { - handle = 0L - syncState.value = MtprotoUpdate.SyncState(false) + internal fun resetHandle(expectedGeneration: Long? = null) { + val oldHandle = + synchronized(handleLock) { + if (expectedGeneration != null && handleGeneration != expectedGeneration) return + handle.also { + handleGeneration++ + handle = 0L + syncState.value = MtprotoUpdate.SyncState(false) + } } - } connectedHandle = 0L - if (proxyState == ProxyConnectionStatus.Connected) proxyState = - ProxyConnectionStatus.Disconnected + if (proxyState == ProxyConnectionStatus.Connected) { + proxyState = + ProxyConnectionStatus.Disconnected + } if (updatesStartedHandle == oldHandle) updatesStartedHandle = 0L + if (updatesStartRetryHandle == oldHandle) updatesStartRetryHandle = 0L if (oldHandle != 0L) native.destroyClient(oldHandle) updatesWake.trySend(Unit) } diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt index b9075043c..e2755790b 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt @@ -3,9 +3,9 @@ package org.monogram.network.bridge.updates import org.monogram.core.common.AppLog import org.monogram.core.models.PeerId import org.monogram.network.bridge.MtprotoUpdate -import org.monogram.network.bridge.message.toModel as toDraftModel import org.monogram.network.bridge.chat.toChatModels import uniffi.monogram_mtproto.UpdateEventDto +import org.monogram.network.bridge.message.toModel as toDraftModel import org.monogram.network.bridge.message.toModel as toMessageModel /** @@ -13,66 +13,99 @@ import org.monogram.network.bridge.message.toModel as toMessageModel * request a refresh, so they map to nothing and the caller schedules the fetch. */ internal fun UpdateEventDto.toMtprotoUpdates(): List = when (val event = this) { - is UpdateEventDto.ChatsChanged, is UpdateEventDto.FoldersChanged -> emptyList() + is UpdateEventDto.ChatsChanged, is UpdateEventDto.FoldersChanged -> { + emptyList() + } - is UpdateEventDto.OutgoingMessageAcknowledged -> listOf( - MtprotoUpdate.OutgoingMessageAcknowledged( - event.randomId, event.result.toMessageModel(), + is UpdateEventDto.OutgoingMessageAcknowledged -> { + listOf( + MtprotoUpdate.OutgoingMessageAcknowledged( + event.randomId, + event.result.toMessageModel(), + ), ) - ) + } + + is UpdateEventDto.ScheduledMessageChanged -> { + listOf(MtprotoUpdate.ScheduledMessageChanged(event.message.toMessageModel())) + } - is UpdateEventDto.ScheduledMessageChanged -> listOf(MtprotoUpdate.ScheduledMessageChanged(event.message.toMessageModel())) - is UpdateEventDto.ScheduledMessagesDeleted -> listOf( - MtprotoUpdate.ScheduledMessagesDeleted( - PeerId(event.chatId), event.messageIds, event.sentMessageIds, + is UpdateEventDto.ScheduledMessagesDeleted -> { + listOf( + MtprotoUpdate.ScheduledMessagesDeleted( + PeerId(event.chatId), + event.messageIds, + event.sentMessageIds, + ), ) - ) + } - is UpdateEventDto.PollChanged -> listOf(MtprotoUpdate.PollChanged(event.pollId)) - is UpdateEventDto.DraftChanged -> listOf(MtprotoUpdate.DraftChanged(event.draft.toDraftModel())) + is UpdateEventDto.PollChanged -> { + listOf(MtprotoUpdate.PollChanged(event.pollId)) + } + + is UpdateEventDto.DraftChanged -> { + listOf(MtprotoUpdate.DraftChanged(event.draft.toDraftModel())) + } is UpdateEventDto.NewMessage -> { AppLog.api("updates", "newMessage chat=${event.message.chatId} id=${event.message.id}") listOf(MtprotoUpdate.NewMessage(event.message.toMessageModel())) } + is UpdateEventDto.MessageContentInvalidated -> { + error("Message content must be resolved before delivery") + } + is UpdateEventDto.MessageEdited -> { - AppLog.api("updates", "messageEdited chat=${event.message.chatId} id=${event.message.id}") + AppLog.api( + "updates", "messageEdited chat=${event.message.chatId} id=${event.message.id}" + ) listOf(MtprotoUpdate.MessageEdited(event.message.toMessageModel())) } - is UpdateEventDto.MessagesDeleted -> listOf( - MtprotoUpdate.MessagesDeleted( - chatId = event.chatId?.let(::PeerId), - messageIds = event.messageIds, - ), - ) - - is UpdateEventDto.PeerTyping -> listOf( - MtprotoUpdate.PeerTyping( - chatId = PeerId(event.chatId), - userId = PeerId(event.userId), - typing = event.typing, - action = event.action.ifBlank { if (event.typing) "typing" else "" }, - ), - ) - - is UpdateEventDto.PeerEmojiStatus -> listOf( - MtprotoUpdate.PeerEmojiStatus(userId = PeerId(event.userId), documentId = event.documentId), - ) - - is UpdateEventDto.PeerStatus -> listOf( - MtprotoUpdate.PeerStatus( - userId = PeerId(event.userId), - status = event.status, - statusAt = event.statusAt, - ), - ) + is UpdateEventDto.MessagesDeleted -> { + listOf( + MtprotoUpdate.MessagesDeleted( + chatId = event.chatId?.let(::PeerId), + messageIds = event.messageIds, + ), + ) + } + + is UpdateEventDto.PeerTyping -> { + listOf( + MtprotoUpdate.PeerTyping( + chatId = PeerId(event.chatId), + userId = PeerId(event.userId), + typing = event.typing, + action = event.action.ifBlank { if (event.typing) "typing" else "" }, + ), + ) + } + + is UpdateEventDto.PeerEmojiStatus -> { + listOf( + MtprotoUpdate.PeerEmojiStatus( + userId = PeerId(event.userId), documentId = event.documentId + ), + ) + } + + is UpdateEventDto.PeerStatus -> { + listOf( + MtprotoUpdate.PeerStatus( + userId = PeerId(event.userId), + status = event.status, + statusAt = event.statusAt, + ), + ) + } is UpdateEventDto.ReadInbox -> { AppLog.api( "read-state", - "server chat=${event.chatId} max=${event.maxId} unread=${event.stillUnread}" + "server chat=${event.chatId} max=${event.maxId} unread=${event.stillUnread}", ) listOf( MtprotoUpdate.ReadInbox( @@ -83,38 +116,51 @@ internal fun UpdateEventDto.toMtprotoUpdates(): List = when (val ) } - is UpdateEventDto.ReadOutbox -> listOf( - MtprotoUpdate.ReadOutbox(chatId = PeerId(event.chatId), maxId = event.maxId), - ) + is UpdateEventDto.ReadOutbox -> { + listOf( + MtprotoUpdate.ReadOutbox(chatId = PeerId(event.chatId), maxId = event.maxId), + ) + } - is UpdateEventDto.Ignored -> ignoredUpdates(event.kind) + is UpdateEventDto.Ignored -> { + ignoredUpdates(event.kind) + } - is UpdateEventDto.SavedGifsChanged -> listOf(MtprotoUpdate.SavedGifsChanged) + is UpdateEventDto.SavedGifsChanged -> { + listOf(MtprotoUpdate.SavedGifsChanged) + } - is UpdateEventDto.MessageReactions -> listOf( - MtprotoUpdate.MessageReactions( - chatId = PeerId(event.chatId), - messageId = event.messageId, - reactionsJson = event.reactionsJson, - ), - ) + is UpdateEventDto.MessageReactions -> { + listOf( + MtprotoUpdate.MessageReactions( + chatId = PeerId(event.chatId), + messageId = event.messageId, + reactionsJson = event.reactionsJson, + ), + ) + } - is UpdateEventDto.DiscussionInbox -> listOf( - MtprotoUpdate.DiscussionInbox( - channelId = PeerId(event.channelId), - topMessageId = event.topMessageId, - readMaxId = event.readMaxId, - ), - ) + is UpdateEventDto.DiscussionInbox -> { + listOf( + MtprotoUpdate.DiscussionInbox( + channelId = PeerId(event.channelId), + topMessageId = event.topMessageId, + readMaxId = event.readMaxId, + ), + ) + } - is UpdateEventDto.DialogsPatched -> + is UpdateEventDto.DialogsPatched -> { if (event.chats.isEmpty()) { emptyList() } else { listOf(MtprotoUpdate.ChatsChanged(event.chats.toChatModels(assignPinOrder = false))) } + } - is UpdateEventDto.SyncState -> listOf(MtprotoUpdate.SyncState(event.isSyncing)) + is UpdateEventDto.SyncState -> { + listOf(MtprotoUpdate.SyncState(event.isSyncing)) + } } private fun ignoredUpdates(kind: String): List { diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesPump.kt b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesPump.kt index d141f23e3..3aa3958f8 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesPump.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesPump.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome @@ -16,7 +17,7 @@ import org.monogram.network.bridge.DurableUpdateBatch import org.monogram.network.bridge.DurableUpdateSink import org.monogram.network.bridge.MtprotoUpdate import org.monogram.network.bridge.UpdatesCursor -import org.monogram.network.bridge.chat.toChatModels +import org.monogram.network.bridge.chat.ChatApi import org.monogram.network.bridge.session.DispatchClass import org.monogram.network.bridge.session.SessionCore import org.monogram.network.bridge.session.dispatchClassName @@ -32,16 +33,16 @@ import org.monogram.network.bridge.profile.toModel as toProfileModel private const val EMITTED_WINDOW = 512 private const val REPLAY_WAIT_MS = 200L -internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { +internal class UpdatesPump( + private val core: SessionCore, +) : UpdatesOps { private var premiumRefreshPending = false private var completionHandle = 0L private var durablePendingHandle = 0L private var syncStartedAtMs = 0L private fun publishCompletionIfReady(handle: Long) { - if (completionHandle == handle && - (durablePendingHandle != handle || !core.replayPending.get()) - ) { + if (completionHandle == handle && (durablePendingHandle != handle || !core.replayPending.get())) { core.publishSyncState(handle, false) if (syncStartedAtMs != 0L) { PerfLog.mark( @@ -54,6 +55,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { completionHandle = 0L } } + private var chatsRefreshPending = false private var foldersRefreshPending = false @@ -61,20 +63,26 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { core.scope.launch { drainUpdatesLoop() } } - override suspend fun getUpdatesState(): Outcome = core.coalesce("getUpdatesState") { - when (val connected = core.ensureConnected()) { - is Outcome.Err -> connected - is Outcome.Ok -> core.rpc("getUpdatesState failed") { activeHandle -> - val state = core.native.getUpdatesState(activeHandle) - UpdatesCursor( - pts = state.pts, - qts = state.qts, - date = state.date, - seq = state.seq, - ) + override suspend fun getUpdatesState(): Outcome = + core.coalesce("getUpdatesState") { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> { + connected + } + + is Outcome.Ok -> { + core.rpc("getUpdatesState failed") { activeHandle -> + val state = core.native.getUpdatesState(activeHandle) + UpdatesCursor( + pts = state.pts, + qts = state.qts, + date = state.date, + seq = state.seq, + ) + } + } } } - } private suspend fun drainUpdatesLoop() { var drainDelayMs = 25L @@ -86,13 +94,38 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { val chatsInFlight = AtomicBoolean(false) val premiumInFlight = AtomicBoolean(false) var cooldownHandle = 0L - fun cooldownDue(last: AtomicLong, windowMs: Long, now: Long): Boolean { + + fun cooldownDue( + last: AtomicLong, + windowMs: Long, + now: Long, + ): Boolean { val value = last.get() return value < 0L || now - value >= windowMs } while (true) { val activeHandle = core.activeHandleOrZero() - if (activeHandle == 0L || core.sessionDead || core.connectedHandle != activeHandle) { + if (activeHandle == 0L || core.sessionDead || core.connectedHandle != activeHandle || core.updatesStartedHandle != activeHandle) { + if (activeHandle != 0L && !core.sessionDead && core.connectedHandle == activeHandle && core.updatesStartRetryHandle == activeHandle) { + drainDelayMs = (drainDelayMs * 2).coerceIn(4_000L, 30_000L) + withTimeoutOrNull(drainDelayMs) { + while (core.isCurrentHandle(activeHandle) && !core.sessionDead && core.updatesStartedHandle != activeHandle) { + if (core.updatesWake.receiveCatching().isClosed) return@withTimeoutOrNull + } + } + if (core.isCurrentHandle(activeHandle) && core.connectedHandle == activeHandle) { + try { + core.maybeStartUpdates(activeHandle) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (core.isCurrentHandle(activeHandle)) { + core.fail(e, "retry startUpdates failed") + } + } + } + continue + } if (activeHandle != 0L && !core.sessionDead && reconnectHandle == activeHandle) { if (core.connect() is Outcome.Err) { drainDelayMs = (drainDelayMs * 2).coerceIn(4_000L, 30_000L) @@ -128,7 +161,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { val drained = nativeRequest( core.native, core.nativeDispatcher, - DispatchClass.BACKGROUND_READ + DispatchClass.BACKGROUND_READ, ) { core.native.drainUpdateBatches(activeHandle) } @@ -146,7 +179,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { result = "ok", detail = "count=$count batches=${drained.batches.size} lane=${ dispatchClassName( - DispatchClass.BACKGROUND_READ + DispatchClass.BACKGROUND_READ, ) }", ) @@ -157,19 +190,21 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { core.replayPending.set(true) if (core.durableSink == null) drainDelayMs = REPLAY_WAIT_MS } - if (core.isCurrentHandle(activeHandle)) drained else UpdateDrainDto( - emptyList(), - emptyList(), - false - ) + if (core.isCurrentHandle(activeHandle)) { + drained + } else { + UpdateDrainDto( + emptyList(), + emptyList(), + false, + ) + } } catch (e: CancellationException) { throw e } catch (e: Exception) { if (core.isCurrentHandle(activeHandle)) { val failure = core.fail(e, "drain updates failed") - if (failure.telegramError.kind == TelegramError.Kind.Network || - failure.telegramError.kind == TelegramError.Kind.Internal - ) { + if (failure.telegramError.kind == TelegramError.Kind.Network || failure.telegramError.kind == TelegramError.Kind.Internal) { reconnectHandle = activeHandle } drainDelayMs = (drainDelayMs * 2).coerceIn(4_000L, 30_000L) @@ -180,31 +215,28 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { durablePendingHandle = activeHandle } for (batch in drain.batches) { - deliverEvents(batch.events, batch.accountGeneration, batch.batchId, activeHandle) - } - deliverEvents(drain.liveEvents, 0L, 0L, activeHandle) - for (batch in drain.batches) { - rememberEmitted(batch) - if (!commitAndAcknowledge(activeHandle, batch)) { - core.replayPending.set(true) + if (!commitDeliverAndAcknowledge(activeHandle, batch)) { + if (core.isCurrentHandle(activeHandle)) core.replayPending.set(true) break } } + deliverEvents(drain.liveEvents, 0L, 0L, activeHandle) publishCompletionIfReady(activeHandle) // Chat metadata events carry no self-user flags. Coalesce the fallback // lookup after delivering messages and bound it during busy updates. val now = core.clock.elapsedMs() - val refreshHandle = activeHandle - if (foldersRefreshPending && - cooldownDue(lastMetadataRefreshMs, 2_000L, now) && - foldersInFlight.compareAndSet(false, true) + if (foldersRefreshPending && cooldownDue( + lastMetadataRefreshMs, + 2_000L, + now, + ) && foldersInFlight.compareAndSet(false, true) ) { core.scope.launch { try { val folders = core.onNativeIfFree { core.native.getFolders(it).map { dto -> dto.toFolderModel() } } - if (folders != null && core.isCurrentHandle(refreshHandle)) { + if (folders != null && core.isCurrentHandle(activeHandle)) { lastMetadataRefreshMs.set(core.clock.elapsedMs()) foldersRefreshPending = false core.updatesEvents.emit(MtprotoUpdate.FoldersChanged(folders)) @@ -221,7 +253,10 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { "refresh folders failed", ) val waitSec = if (telegram.kind == TelegramError.Kind.Flood) { - (telegram.retryAfterSeconds ?: telegram.argument ?: 3).coerceIn(3, 60) + (telegram.retryAfterSeconds ?: telegram.argument ?: 3).coerceIn( + 3, + 60, + ) } else { core.fail(e, "refresh folders failed", degradeHome = false) 15 @@ -234,16 +269,16 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } } } - if (chatsRefreshPending && core.isCurrentHandle(activeHandle) && - cooldownDue(lastChatsRefreshMs, 2_000L, now) && - chatsInFlight.compareAndSet(false, true) + if (chatsRefreshPending && core.isCurrentHandle(activeHandle) && cooldownDue( + lastChatsRefreshMs, + 2_000L, + now, + ) && chatsInFlight.compareAndSet(false, true) ) { core.scope.launch { try { - val chats = core.onNativeIfFree { handle -> - core.native.getChats(handle).toChatModels() - } - if (chats != null && core.isCurrentHandle(refreshHandle)) { + val chats = ChatApi(core).getChatsIfIdle() + if (chats != null && core.isCurrentHandle(activeHandle)) { lastChatsRefreshMs.set(core.clock.elapsedMs()) chatsRefreshPending = false core.updatesEvents.emit(MtprotoUpdate.ChatsChanged(chats)) @@ -251,16 +286,20 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } catch (e: CancellationException) { throw e } catch (e: Exception) { - core.fail(e, "refresh chats failed", degradeHome = false) - lastChatsRefreshMs.set(core.clock.elapsedMs() + 13_000L) + if (core.isCurrentHandle(activeHandle)) { + core.fail(e, "refresh chats failed", degradeHome = false) + lastChatsRefreshMs.set(core.clock.elapsedMs() + 13_000L) + } } finally { chatsInFlight.set(false) } } } - if (premiumRefreshPending && core.isCurrentHandle(activeHandle) && - cooldownDue(lastPremiumRefreshMs, 60_000L, now) && - premiumInFlight.compareAndSet(false, true) + if (premiumRefreshPending && core.isCurrentHandle(activeHandle) && cooldownDue( + lastPremiumRefreshMs, + 60_000L, + now, + ) && premiumInFlight.compareAndSet(false, true) ) { core.scope.launch { val profile = try { @@ -276,7 +315,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } finally { premiumInFlight.set(false) } - if (profile != null && core.isCurrentHandle(refreshHandle)) { + if (profile != null && core.isCurrentHandle(activeHandle)) { lastPremiumRefreshMs.set(core.clock.elapsedMs()) premiumRefreshPending = false core.updatesEvents.emit(MtprotoUpdate.AccountPremium(profile.isPremium)) @@ -293,6 +332,23 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } } + private suspend fun resolveEvent( + event: UpdateEventDto, + handle: Long, + ): List { + if (event !is UpdateEventDto.MessageContentInvalidated) return event.toMtprotoUpdates() + val message = + nativeRequest(core.native, core.nativeDispatcher, DispatchClass.BACKGROUND_READ) { + core.native.getMessage(handle, event.chatId, event.messageId) + } + if (!core.isCurrentHandle(handle)) throw CancellationException("Account changed") + return if (message == null) { + UpdateEventDto.MessagesDeleted(event.chatId, listOf(event.messageId)).toMtprotoUpdates() + } else { + UpdateEventDto.MessageEdited(message).toMtprotoUpdates() + } + } + private val emitted = LinkedHashSet>() private fun rememberEmitted(batch: UpdateBatchDto) { @@ -301,24 +357,34 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } /** - * Commits one batch through the application sink, then acknowledges it. The - * acknowledgement is only a native list removal; nothing here waits for Room - * while a native lock is held. Returns false when the batch must stay queued. + * Commits one batch through the application sink, delivers its events, then + * acknowledges it. Returns false when the batch must stay queued for replay. */ - private suspend fun commitAndAcknowledge(handle: Long, batch: UpdateBatchDto): Boolean { + private suspend fun commitDeliverAndAcknowledge( + handle: Long, + batch: UpdateBatchDto, + ): Boolean { val sink = core.durableSink ?: return false + val mapped = try { + batch.events.map { resolveEvent(it, handle) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.warn("updates", "durable mapping failed") + return false + } val committed = try { sink.commit( DurableUpdateBatch( accountGeneration = batch.accountGeneration, batchId = batch.batchId, - updates = batch.events.flatMap { it.toMtprotoUpdates() }, + updates = mapped.flatten(), cursor = batch.cursorAfter?.let { UpdatesCursor( it.pts, it.qts, it.date, - it.seq + it.seq, ) }, channelCursors = batch.channelPtsAfter.associate { it.chatId to it.pts }, @@ -331,10 +397,22 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { false } if (!committed || !core.isCurrentHandle(handle)) return false - nativeRequest(core.native, core.nativeDispatcher, DispatchClass.BACKGROUND_READ) { - core.native.acknowledgeUpdateBatch(handle, batch.accountGeneration, batch.batchId) + if ((batch.accountGeneration to batch.batchId) !in emitted) { + deliverEvents(batch.events, batch.accountGeneration, batch.batchId, handle, mapped) + if (!core.isCurrentHandle(handle)) return false + rememberEmitted(batch) + } + return try { + nativeRequest(core.native, core.nativeDispatcher, DispatchClass.BACKGROUND_READ) { + core.native.acknowledgeUpdateBatch(handle, batch.accountGeneration, batch.batchId) + } + core.isCurrentHandle(handle) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.warn("updates", "durable acknowledgement failed") + false } - return true } /** @@ -349,12 +427,9 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } if (!core.isCurrentHandle(handle)) return false for (batch in pending) { - if ((batch.accountGeneration to batch.batchId) !in emitted) { - deliverEvents(batch.events, batch.accountGeneration, batch.batchId, handle) - rememberEmitted(batch) - } - if (!commitAndAcknowledge(handle, batch)) return false + if (!commitDeliverAndAcknowledge(handle, batch)) return false } + if (!core.isCurrentHandle(handle)) return false core.replayPending.set(false) return true } @@ -364,8 +439,9 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { accountGeneration: Long, acceptedOrder: Long, handle: Long, + mapped: List>? = null, ) { - for (event in events) { + for ((index, event) in events.withIndex()) { if (!core.isCurrentHandle(handle)) return when (event) { is UpdateEventDto.SyncState -> { @@ -386,17 +462,30 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { premiumRefreshPending = true } - is UpdateEventDto.FoldersChanged -> foldersRefreshPending = true - else -> event.toMtprotoUpdates().forEach { update -> - val ordered = if (update is MtprotoUpdate.NewMessage) { - update.copy( - accountGeneration = accountGeneration, - acceptedOrder = acceptedOrder, - ) - } else { - update + is UpdateEventDto.FoldersChanged -> { + foldersRefreshPending = true + } + + else -> { + (mapped?.get(index) ?: resolveEvent(event, handle)).forEach { update -> + val ordered = when (update) { + is MtprotoUpdate.NewMessage -> { + update.copy( + accountGeneration = accountGeneration, + acceptedOrder = acceptedOrder, + ) + } + + is MtprotoUpdate.MessagesDeleted -> { + update.copy(persisted = mapped != null) + } + + else -> { + update + } + } + core.updatesEvents.emit(ordered) } - core.updatesEvents.emit(ordered) } } } @@ -410,5 +499,6 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { override fun updates(): Flow = merge(core.updatesEvents, core.localUpdates, core.syncState) + override fun sessionLost(): Flow = core.sessionLostFlow.asSharedFlow() } diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt index 5d5bd8eeb..3da37b100 100644 --- a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt +++ b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt @@ -11,9 +11,12 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test import org.monogram.core.common.Outcome @@ -36,686 +39,818 @@ import java.util.concurrent.TimeUnit @OptIn(ExperimentalCoroutinesApi::class) class BridgedMtprotoClientLifecycleTest { @Test - fun handleCreateAppliesInitConnectionInfo() = runTest { - val native = object : RecordingNative() { - var applied: List? = null - override fun setInitConnectionInfo( - deviceModel: String, - systemVersion: String, - appVersion: String, - systemLangCode: String, - langPack: String, - langCode: String, - ) { - applied = listOf( - deviceModel, - systemVersion, - appVersion, - systemLangCode, - langPack, - langCode, + fun handleCreateAppliesInitConnectionInfo() = + runTest { + val native = + object : RecordingNative() { + var applied: List? = null + + override fun setInitConnectionInfo( + deviceModel: String, + systemVersion: String, + appVersion: String, + systemLangCode: String, + langPack: String, + langCode: String, + ) { + applied = + listOf( + deviceModel, + systemVersion, + appVersion, + systemLangCode, + langPack, + langCode, + ) + } + + override fun createClient( + apiId: Int, + apiHash: String, + sessionPath: String, + ): Long { + assertEquals( + listOf("Pixel 8", "14", "Monogram 0.1", "en", "android", "en-US"), + applied, + ) + return super.createClient(apiId, apiHash, sessionPath) + } + + override fun isAuthorized(handle: Long) = false + } + val client = + client( + native, + StandardTestDispatcher(testScheduler), + initConnection = + ClientInitInfo( + deviceModel = "Pixel 8", + systemVersion = "14", + appVersion = "Monogram 0.1", + systemLangCode = "en", + langPack = "android", + langCode = "en-US", + ), ) + try { + assertEquals(Outcome.Ok(false), client.isLocallyAuthorized()) + assertEquals(1, native.created) + } finally { + client.close() } - override fun createClient(apiId: Int, apiHash: String, sessionPath: String): Long { - assertEquals( - listOf("Pixel 8", "14", "Monogram 0.1", "en", "android", "en-US"), - applied, - ) - return super.createClient(apiId, apiHash, sessionPath) - } - override fun isAuthorized(handle: Long) = false - } - val client = client( - native, - StandardTestDispatcher(testScheduler), - initConnection = ClientInitInfo( - deviceModel = "Pixel 8", - systemVersion = "14", - appVersion = "Monogram 0.1", - systemLangCode = "en", - langPack = "android", - langCode = "en-US", - ), - ) - try { - assertEquals(Outcome.Ok(false), client.isLocallyAuthorized()) - assertEquals(1, native.created) - } finally { - client.close() } - } @Test - fun localAuthorizationRestoresSessionWithoutConnecting() = runTest { - for (authorized in listOf(true, false)) { - val native = object : RecordingNative() { - override fun connect(handle: Long): Unit = error("Startup must not connect") - override fun isAuthorized(handle: Long): Boolean { - assertEquals(1L, handle) - return authorized + fun localAuthorizationRestoresSessionWithoutConnecting() = + runTest { + for (authorized in listOf(true, false)) { + val native = + object : RecordingNative() { + override fun connect(handle: Long): Unit = error("Startup must not connect") + + override fun isAuthorized(handle: Long): Boolean { + assertEquals(1L, handle) + return authorized + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertEquals(Outcome.Ok(authorized), client.isLocallyAuthorized()) + assertEquals(1, native.created) + } finally { + client.close() } } + } + + @Test + fun localAuthorizationPreservesCancellation() = + runTest { + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long): Boolean = + throw CancellationException("cancelled") + } val client = client(native, StandardTestDispatcher(testScheduler)) try { - assertEquals(Outcome.Ok(authorized), client.isLocallyAuthorized()) - assertEquals(1, native.created) + client.isLocallyAuthorized() + error("Expected cancellation") + } catch (e: CancellationException) { + assertEquals("cancelled", e.message) } finally { client.close() } } - } @Test - fun localAuthorizationPreservesCancellation() = runTest { - val native = object : RecordingNative() { - override fun isAuthorized(handle: Long): Boolean = throw CancellationException("cancelled") - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - client.isLocallyAuthorized() - error("Expected cancellation") - } catch (e: CancellationException) { - assertEquals("cancelled", e.message) - } finally { - client.close() + fun restoredAccountSendsOnlineOfflineAndOnlineAgain() = + runTest { + val statuses = mutableListOf() + var connects = 0 + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long) = true + + override fun connect(handle: Long) { + connects++ + } + + override fun updateStatus( + handle: Long, + offline: Boolean, + ) { + statuses += offline + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertEquals(Outcome.Ok(true), client.isLocallyAuthorized()) + assertEquals(0, connects) + for (offline in listOf(false, true, false)) { + assertEquals(Outcome.Ok(Unit), client.updateStatus(offline)) + } + assertEquals(listOf(false, true, false), statuses) + assertEquals(1, connects) + } finally { + client.close() + } } - } @Test - fun restoredAccountSendsOnlineOfflineAndOnlineAgain() = runTest { - val statuses = mutableListOf() - var connects = 0 - val native = object : RecordingNative() { - override fun isAuthorized(handle: Long) = true - override fun connect(handle: Long) { connects++ } - override fun updateStatus(handle: Long, offline: Boolean) { - statuses += offline + fun unauthenticatedAccountDoesNotSendStatus() = + runTest { + val native = + object : RecordingNative() { + override fun updateStatus( + handle: Long, + offline: Boolean, + ): Unit = error("Unauthenticated status must not reach native") + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertEquals(Outcome.Ok(Unit), client.updateStatus(offline = false)) + assertEquals(Outcome.Ok(Unit), client.updateStatus(offline = true)) + } finally { + client.close() } } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertEquals(Outcome.Ok(true), client.isLocallyAuthorized()) - assertEquals(0, connects) - for (offline in listOf(false, true, false)) { - assertEquals(Outcome.Ok(Unit), client.updateStatus(offline)) + + @Test + fun signInPreservesCancellation() = + runTest { + val cancelled = CancellationException("cancelled") + val native = + object : RecordingNative() { + override fun signIn( + handle: Long, + phone: String, + phoneCodeHash: String, + phoneCode: String, + ): AuthSignedIn = throw cancelled + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + client.signIn("", "", "") + error("Expected cancellation") + } catch (e: CancellationException) { + assertEquals(cancelled.message, e.message) + } finally { + client.close() } - assertEquals(listOf(false, true, false), statuses) - assertEquals(1, connects) - } finally { - client.close() } - } @Test - fun unauthenticatedAccountDoesNotSendStatus() = runTest { - val native = object : RecordingNative() { - override fun updateStatus(handle: Long, offline: Boolean): Unit = - error("Unauthenticated status must not reach native") - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertEquals(Outcome.Ok(Unit), client.updateStatus(offline = false)) - assertEquals(Outcome.Ok(Unit), client.updateStatus(offline = true)) - } finally { - client.close() + fun historyPreservesOuterTimeout() = + runTest { + val native = + object : RecordingNative() { + override fun getHistory( + handle: Long, + chatId: Long, + limit: Int, + ): List { + testScheduler.advanceTimeBy(10) + return emptyList() + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + withTimeout(5) { client.getHistory(PeerId(1)) } + error("Expected caller timeout") + } catch (_: TimeoutCancellationException) { + // The caller timeout must not become an Outcome.Err. + } finally { + client.close() + } } - } @Test - fun signInPreservesCancellation() = runTest { - val cancelled = CancellationException("cancelled") - val native = object : RecordingNative() { - override fun signIn(handle: Long, phone: String, phoneCodeHash: String, phoneCode: String): AuthSignedIn = - throw cancelled - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - client.signIn("", "", "") - error("Expected cancellation") - } catch (e: CancellationException) { - assertEquals(cancelled.message, e.message) - } finally { + fun closeIsIdempotentAndCannotRecreateHandle() = + runTest { + val native = RecordingNative() + val client = client(native, StandardTestDispatcher(testScheduler)) + assertTrue(client.connect() is Outcome.Ok) + client.close() client.close() + assertTrue(client.connect() is Outcome.Err) + assertEquals(1, native.created) + assertEquals(listOf(1L), native.destroyed) } - } @Test - fun historyPreservesOuterTimeout() = runTest { - val native = object : RecordingNative() { - override fun getHistory(handle: Long, chatId: Long, limit: Int): List { - testScheduler.advanceTimeBy(10) - return emptyList() + fun repeatConnectReachesNativeOnlyOnce() = + runTest { + var connects = 0 + val native = + object : RecordingNative() { + override fun connect(handle: Long) { + connects++ + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + repeat(5) { assertTrue(client.connect() is Outcome.Ok) } + assertEquals(1, connects) + } finally { + client.close() } } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - withTimeout(5) { client.getHistory(PeerId(1)) } - error("Expected caller timeout") - } catch (_: TimeoutCancellationException) { - // The caller timeout must not become an Outcome.Err. - } finally { - client.close() - } - } @Test - fun closeIsIdempotentAndCannotRecreateHandle() = runTest { - val native = RecordingNative() - val client = client(native, StandardTestDispatcher(testScheduler)) - assertTrue(client.connect() is Outcome.Ok) - client.close() - client.close() - assertTrue(client.connect() is Outcome.Err) - assertEquals(1, native.created) - assertEquals(listOf(1L), native.destroyed) - } + fun readsDoNotReconnectWhileAHandleIsLive() = + runTest { + var connects = 0 + val native = + object : RecordingNative() { + override fun connect(handle: Long) { + connects++ + } - @Test - fun repeatConnectReachesNativeOnlyOnce() = runTest { - var connects = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) { - connects++ + override fun getChats(handle: Long): List = emptyList() + + override fun getFolders(handle: Long): List = + emptyList() + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + assertTrue(client.getChats() is Outcome.Ok) + assertTrue(client.getFolders() is Outcome.Ok) + assertEquals(1, connects) + } finally { + client.close() } } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.connect() is Outcome.Ok) - repeat(5) { assertTrue(client.connect() is Outcome.Ok) } - assertEquals(1, connects) - } finally { - client.close() - } - } @Test - fun readsDoNotReconnectWhileAHandleIsLive() = runTest { - var connects = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) { - connects++ - } + fun repeatedReadsStartUpdatesOncePerHandle() = + runTest { + var starts = 0 + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long) = true - override fun getChats(handle: Long): List = emptyList() + override fun startUpdates(handle: Long) { + starts++ + } - override fun getFolders(handle: Long): List = emptyList() - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.connect() is Outcome.Ok) - assertTrue(client.getChats() is Outcome.Ok) - assertTrue(client.getFolders() is Outcome.Ok) - assertEquals(1, connects) - } finally { - client.close() - } - } + override fun getChats(handle: Long): List = emptyList() - @Test - fun repeatedReadsStartUpdatesOncePerHandle() = runTest { - var starts = 0 - val native = object : RecordingNative() { - override fun isAuthorized(handle: Long) = true - override fun startUpdates(handle: Long) { - starts++ + override fun getFolders(handle: Long): List = + emptyList() + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + assertTrue(client.getChats() is Outcome.Ok) + assertTrue(client.getFolders() is Outcome.Ok) + assertEquals(1, starts) + } finally { + client.close() } - override fun getChats(handle: Long): List = emptyList() - override fun getFolders(handle: Long): List = emptyList() - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.connect() is Outcome.Ok) - assertTrue(client.getChats() is Outcome.Ok) - assertTrue(client.getFolders() is Outcome.Ok) - assertEquals(1, starts) - } finally { - client.close() } - } @Test - fun reconnectAfterTransportFailureReusesTheSameHandleAndSession() = runTest { - var connects = 0 - var chats = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) { - connects++ - } - - override fun getChats(handle: Long): List { - chats++ - if (chats == 1) throw IllegalStateException("connection closed") - return emptyList() + fun reconnectAfterTransportFailureReusesTheSameHandleAndSession() = + runTest { + var connects = 0 + var chats = 0 + val native = + object : RecordingNative() { + override fun connect(handle: Long) { + connects++ + } + + override fun getChats(handle: Long): List { + chats++ + if (chats == 1) throw IllegalStateException("connection closed") + return emptyList() + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + assertTrue(client.getChats() is Outcome.Err) + // Recovery must not create a second client or re-authenticate. + assertTrue(client.connect() is Outcome.Ok) + assertTrue(client.getChats() is Outcome.Ok) + assertEquals(1, native.created) + assertTrue(connects <= 2) + } finally { + client.close() } } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.connect() is Outcome.Ok) - assertTrue(client.getChats() is Outcome.Err) - // Recovery must not create a second client or re-authenticate. - assertTrue(client.connect() is Outcome.Ok) - assertTrue(client.getChats() is Outcome.Ok) - assertEquals(1, native.created) - assertTrue(connects <= 2) - } finally { - client.close() - } - } @Test - fun downloadConcurrencySettingReachesNativeAndReadsBack() = runTest { - val native = object : RecordingNative() { - var lanes = 0 - var parts = 0 - override fun setDownloadConcurrency(lanes: Int, parts: Int) { - this.lanes = lanes - this.parts = parts + fun downloadConcurrencySettingReachesNativeAndReadsBack() = + runTest { + val native = + object : RecordingNative() { + var lanes = 0 + var parts = 0 + + override fun setDownloadConcurrency( + lanes: Int, + parts: Int, + ) { + this.lanes = lanes + this.parts = parts + } + + override fun downloadConcurrency(): List = listOf(lanes, parts) + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + client.applyDownloadConcurrency(8, 16) + assertEquals(8, native.lanes) + assertEquals(16, native.parts) + assertEquals(listOf(8, 16), client.downloadConcurrency()) + client.applyDownloadConcurrency(2, 2) + assertEquals(listOf(2, 2), client.downloadConcurrency()) + } finally { + client.close() } - - override fun downloadConcurrency(): List = listOf(lanes, parts) - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - client.applyDownloadConcurrency(8, 16) - assertEquals(8, native.lanes) - assertEquals(16, native.parts) - assertEquals(listOf(8, 16), client.downloadConcurrency()) - client.applyDownloadConcurrency(2, 2) - assertEquals(listOf(2, 2), client.downloadConcurrency()) - } finally { - client.close() } - } @Test - fun hibernateWaitsForInFlightRequestsBeforeDroppingTheHandle() = runBlocking { - val started = CountDownLatch(1) - val release = CountDownLatch(1) - val native = object : RecordingNative() { - override fun getChats(handle: Long): List { - started.countDown() - release.await() - return emptyList() - } - } - val client = client(native, Dispatchers.IO) - try { - assertTrue(client.connect() is Outcome.Ok) - val call = async(Dispatchers.IO) { client.getChats() } - assertTrue("request never reached native", started.await(5, TimeUnit.SECONDS)) - // Backgrounding the app must not destroy a handle a request is using. - client.hibernate() - assertEquals(emptyList(), native.destroyed) - release.countDown() - assertTrue(call.await() is Outcome.Ok) - // The deferred hibernate applies on the last completion. - val deadline = System.currentTimeMillis() + 5_000 - while (native.destroyed.isEmpty() && System.currentTimeMillis() < deadline) { - Thread.sleep(20) + fun hibernateWaitsForInFlightRequestsBeforeDroppingTheHandle() = + runBlocking { + val started = CountDownLatch(1) + val release = CountDownLatch(1) + val native = + object : RecordingNative() { + override fun getChats(handle: Long): List { + started.countDown() + release.await() + return emptyList() + } + } + val client = client(native, Dispatchers.IO) + try { + assertTrue(client.connect() is Outcome.Ok) + val call = async(Dispatchers.IO) { client.getChats() } + assertTrue("request never reached native", started.await(5, TimeUnit.SECONDS)) + // Backgrounding the app must not destroy a handle a request is using. + client.hibernate() + assertEquals(emptyList(), native.destroyed) + release.countDown() + assertTrue(call.await() is Outcome.Ok) + // The deferred hibernate applies on the last completion. + val deadline = System.currentTimeMillis() + 5_000 + while (native.destroyed.isEmpty() && System.currentTimeMillis() < deadline) { + Thread.sleep(20) + } + assertEquals(listOf(1L), native.destroyed) + } finally { + client.close() } - assertEquals(listOf(1L), native.destroyed) - } finally { - client.close() } - } @Test - fun staleHandleIsRecreatedAndTheCallRetriedOnce() = runTest { - var calls = 0 - val native = object : RecordingNative() { - override fun getChats(handle: Long): List { - calls++ - if (calls == 1) throw MtprotoException.UnknownClient() - return emptyList() + fun staleHandleIsRecreatedAndTheCallRetriedOnce() = + runTest { + var calls = 0 + val native = + object : RecordingNative() { + override fun getChats(handle: Long): List { + calls++ + if (calls == 1) throw MtprotoException.UnknownClient() + return emptyList() + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + val result = client.getChats() + assertTrue("expected a retry, got $result", result is Outcome.Ok) + assertEquals(2, calls) + assertEquals(listOf(1L), native.destroyed) + } finally { + client.close() } } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { + + @Test + fun logoutAllowsNewAuthorizationOnSameClient() = + runTest { + val native = RecordingNative() + val client = client(native, StandardTestDispatcher(testScheduler)) + assertTrue(client.connect() is Outcome.Ok) + assertTrue(client.logout() is Outcome.Ok) assertTrue(client.connect() is Outcome.Ok) - val result = client.getChats() - assertTrue("expected a retry, got $result", result is Outcome.Ok) - assertEquals(2, calls) - assertEquals(listOf(1L), native.destroyed) - } finally { client.close() + assertEquals(2, native.created) + assertEquals(listOf(1L, 2L), native.destroyed) } - } @Test - fun logoutAllowsNewAuthorizationOnSameClient() = runTest { - val native = RecordingNative() - val client = client(native, StandardTestDispatcher(testScheduler)) - assertTrue(client.connect() is Outcome.Ok) - assertTrue(client.logout() is Outcome.Ok) - assertTrue(client.connect() is Outcome.Ok) - client.close() - assertEquals(2, native.created) - assertEquals(listOf(1L, 2L), native.destroyed) - } - - @Test - fun oneDrainCallDeliversTheWholeUpdateBatch() = runTest { - val batchSizes = mutableListOf() - val native = object : RecordingNative() { - override fun isAuthorized(handle: Long) = true - override fun drainUpdates(handle: Long): List { - if (batchSizes.isEmpty()) { - val batch = listOf(newMessageEvent(1), newMessageEvent(2), newMessageEvent(3)) - batchSizes += batch.size - return batch + fun oneDrainCallDeliversTheWholeUpdateBatch() = + runTest { + val batchSizes = mutableListOf() + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long) = true + + override fun drainUpdates(handle: Long): List { + if (batchSizes.isEmpty()) { + val batch = + listOf(newMessageEvent(1), newMessageEvent(2), newMessageEvent(3)) + batchSizes += batch.size + return batch + } + batchSizes += 0 + return emptyList() + } } - batchSizes += 0 - return emptyList() - } - } - val client = client(native, StandardTestDispatcher(testScheduler)) - val ids = mutableListOf() - val collector = backgroundScope.launch { - client.updates().filterIsInstance().collect { - ids += it.message.id.id + val client = client(native, StandardTestDispatcher(testScheduler)) + val ids = mutableListOf() + val collector = + backgroundScope.launch { + client.updates().filterIsInstance().collect { + ids += it.message.id.id + } + } + try { + assertTrue(client.connect() is Outcome.Ok) + testScheduler.advanceTimeBy(50) + testScheduler.runCurrent() + assertEquals(listOf(3), batchSizes.filter { it > 0 }) + assertEquals(listOf(1, 2, 3), ids.toList()) + } finally { + collector.cancel() + client.close() } } - try { - assertTrue(client.connect() is Outcome.Ok) - testScheduler.advanceTimeBy(50) - testScheduler.runCurrent() - assertEquals(listOf(3), batchSizes.filter { it > 0 }) - assertEquals(listOf(1, 2, 3), ids.toList()) - } finally { - collector.cancel() - client.close() - } - } @Test - fun updateDrainStartsWithoutStartupDelay() = runTest { - var drains = 0 - val native = object : RecordingNative() { - override fun isAuthorized(handle: Long) = true - override fun drainUpdates(handle: Long): List { - drains++ - return emptyList() + fun updateDrainStartsWithoutStartupDelay() = + runTest { + var drains = 0 + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long) = true + + override fun drainUpdates(handle: Long): List { + drains++ + return emptyList() + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + testScheduler.runCurrent() + testScheduler.advanceTimeBy(50) + testScheduler.runCurrent() + assertTrue( + "drain must start before the old 1500ms delay, drains=$drains", + drains > 0 + ) + } finally { + client.close() } } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.connect() is Outcome.Ok) - testScheduler.runCurrent() - testScheduler.advanceTimeBy(50) - testScheduler.runCurrent() - assertTrue("drain must start before the old 1500ms delay, drains=$drains", drains > 0) - } finally { - client.close() - } - } @Test - fun reconnectDoesNotStartASecondDrainLoop() = runTest { - var drains = 0 - val native = object : RecordingNative() { - override fun isAuthorized(handle: Long) = true - override fun drainUpdates(handle: Long): List { - drains++ - return emptyList() + fun reconnectDoesNotStartASecondDrainLoop() = + runTest { + var drains = 0 + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long) = true + + override fun drainUpdates(handle: Long): List { + drains++ + return emptyList() + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + testScheduler.advanceTimeBy(250) + testScheduler.runCurrent() + val firstWindow = drains + assertTrue("expected ~10 drains in 250ms, was $firstWindow", firstWindow in 5..20) + client.hibernate() + testScheduler.runCurrent() + assertTrue(client.connect() is Outcome.Ok) + val afterReconnect = drains + testScheduler.advanceTimeBy(250) + testScheduler.runCurrent() + val extra = drains - afterReconnect + assertTrue("duplicate drainers extra=$extra first=$firstWindow", extra in 5..20) + } finally { + client.close() } } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.connect() is Outcome.Ok) - testScheduler.advanceTimeBy(250) - testScheduler.runCurrent() - val firstWindow = drains - assertTrue("expected ~10 drains in 250ms, was $firstWindow", firstWindow in 5..20) - client.hibernate() - testScheduler.runCurrent() - assertTrue(client.connect() is Outcome.Ok) - val afterReconnect = drains - testScheduler.advanceTimeBy(250) - testScheduler.runCurrent() - val extra = drains - afterReconnect - assertTrue("duplicate drainers extra=$extra first=$firstWindow", extra in 5..20) - } finally { - client.close() - } - } @Test - fun updatesSurviveTemporaryUiUnsubscription() = runTest { - val pending = ArrayDeque() - pending.add(newMessageEvent(7)) - val native = object : RecordingNative() { - override fun isAuthorized(handle: Long) = true - override fun drainUpdates(handle: Long): List { - val next = pending.removeFirstOrNull() ?: return emptyList() - return listOf(next) + fun updatesSurviveTemporaryUiUnsubscription() = + runTest { + val pending = ArrayDeque() + pending.add(newMessageEvent(7)) + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long) = true + + override fun drainUpdates(handle: Long): List { + val next = pending.removeFirstOrNull() ?: return emptyList() + return listOf(next) + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + val persisted = mutableListOf() + val sessionCollector = + backgroundScope.launch { + client.updates().filterIsInstance().collect { + persisted += it.message.id.id + } + } + try { + assertTrue(client.connect() is Outcome.Ok) + testScheduler.advanceTimeBy(50) + testScheduler.runCurrent() + assertEquals(listOf(7), persisted.toList()) + val ui = launch { client.updates().collect() } + testScheduler.runCurrent() + ui.cancel() + testScheduler.runCurrent() + pending.add(newMessageEvent(8)) + testScheduler.advanceTimeBy(50) + testScheduler.runCurrent() + assertEquals(listOf(7, 8), persisted.toList()) + } finally { + sessionCollector.cancel() + client.close() } } - val client = client(native, StandardTestDispatcher(testScheduler)) - val persisted = mutableListOf() - val sessionCollector = backgroundScope.launch { - client.updates().filterIsInstance().collect { - persisted += it.message.id.id + + @Test + fun connectRecordsASingleDebugStat() = + runTest { + org.monogram.core.common.DebugStats + .resetForTests(true) + try { + var connects = 0 + val native = + object : RecordingNative() { + override fun connect(handle: Long) { + connects++ + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + assertEquals(1, connects) + val rows = + org.monogram.core.common.DebugStats + .snapshot() + .records + .filter { it.op == "bridge:connect" } + assertEquals(1, rows.size) + } finally { + client.close() + } + } finally { + org.monogram.core.common.DebugStats + .resetForTests(false) } } - try { - assertTrue(client.connect() is Outcome.Ok) - testScheduler.advanceTimeBy(50) - testScheduler.runCurrent() - assertEquals(listOf(7), persisted.toList()) - val ui = launch { client.updates().collect() } - testScheduler.runCurrent() - ui.cancel() - testScheduler.runCurrent() - pending.add(newMessageEvent(8)) - testScheduler.advanceTimeBy(50) - testScheduler.runCurrent() - assertEquals(listOf(7, 8), persisted.toList()) - } finally { - sessionCollector.cancel() - client.close() - } - } @Test - fun connectRecordsASingleDebugStat() = runTest { - org.monogram.core.common.DebugStats.resetForTests(true) - try { + fun parallelConnectReachesNativeOnlyOnce() = + runTest { var connects = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) { - connects++ + val native = + object : RecordingNative() { + override fun connect(handle: Long) { + connects++ + } } - } val client = client(native, StandardTestDispatcher(testScheduler)) try { - assertTrue(client.connect() is Outcome.Ok) + val results = (1..8).map { async { client.connect() } }.map { it.await() } + assertTrue(results.all { it is Outcome.Ok }) assertEquals(1, connects) - val rows = org.monogram.core.common.DebugStats.snapshot().records.filter { it.op == "bridge:connect" } - assertEquals(1, rows.size) + assertEquals(1, native.created) } finally { client.close() } - } finally { - org.monogram.core.common.DebugStats.resetForTests(false) } - } @Test - fun parallelConnectReachesNativeOnlyOnce() = runTest { - var connects = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) { - connects++ + fun parallelGetChatsReachesNativeOnlyOnce() = + runBlocking { + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + var chats = 0 + val native = + object : RecordingNative() { + override fun connect(handle: Long) = Unit + + override fun getChats(handle: Long): List { + chats++ + entered.countDown() + check(release.await(2, TimeUnit.SECONDS)) + return emptyList() + } + } + val client = client(native, Dispatchers.IO) + try { + assertTrue(client.connect() is Outcome.Ok) + val first = async(Dispatchers.IO) { client.getChats() } + val second = async(Dispatchers.IO) { client.getChats() } + check(entered.await(2, TimeUnit.SECONDS)) + Thread.sleep(50) + release.countDown() + assertTrue(first.await() is Outcome.Ok) + assertTrue(second.await() is Outcome.Ok) + assertEquals(1, chats) + } finally { + client.close() } } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - val results = (1..8).map { async { client.connect() } }.map { it.await() } - assertTrue(results.all { it is Outcome.Ok }) - assertEquals(1, connects) - assertEquals(1, native.created) - } finally { - client.close() - } - } @Test - fun parallelGetChatsReachesNativeOnlyOnce() = runBlocking { - val entered = CountDownLatch(1) - val release = CountDownLatch(1) - var chats = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) = Unit - override fun getChats(handle: Long): List { - chats++ - entered.countDown() - check(release.await(2, TimeUnit.SECONDS)) - return emptyList() + fun sendAndSideRpcsDoNotReconnectWhileConnected() = + runTest { + var connects = 0 + var sidecar = 0 + val native = + object : RecordingNative() { + override fun connect(handle: Long) { + connects++ + } + + override fun getHistory( + handle: Long, + chatId: Long, + limit: Int, + ): List = emptyList() + + override fun getChats(handle: Long) = emptyList() + + override fun getUpdatesState(handle: Long) = UpdatesStateDto(1, 0, 1, 0) + + override fun sendTextMessage( + handle: Long, + chatId: Long, + text: String, + replyToMsgId: Int, + entitiesJson: String?, + topMsgId: Int, + webpageUrl: String?, + randomId: Long, + ) = sampleMessage(chatId, 9) + } + val client = client(native, StandardTestDispatcher(testScheduler)) { sidecar++ } + try { + assertTrue(client.connect() is Outcome.Ok) + assertEquals(1, connects) + assertEquals(1, sidecar) + val history = async { client.getHistory(PeerId(1)) } + val send = async { client.sendText(PeerId(1), "hi") } + val chats = async { client.getChats() } + val state = async { client.getUpdatesState() } + assertTrue(history.await() is Outcome.Ok) + assertTrue(send.await() is Outcome.Ok) + assertTrue(chats.await() is Outcome.Ok) + assertTrue(state.await() is Outcome.Ok) + assertEquals(1, connects) + assertEquals(1, sidecar) + } finally { + client.close() } } - val client = client(native, Dispatchers.IO) - try { - assertTrue(client.connect() is Outcome.Ok) - val first = async(Dispatchers.IO) { client.getChats() } - val second = async(Dispatchers.IO) { client.getChats() } - check(entered.await(2, TimeUnit.SECONDS)) - Thread.sleep(50) - release.countDown() - assertTrue(first.await() is Outcome.Ok) - assertTrue(second.await() is Outcome.Ok) - assertEquals(1, chats) - } finally { - client.close() - } - } @Test - fun sendAndSideRpcsDoNotReconnectWhileConnected() = runTest { - var connects = 0 - var sidecar = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) { - connects++ - } - override fun getHistory(handle: Long, chatId: Long, limit: Int): List = emptyList() - override fun getChats(handle: Long) = emptyList() - override fun getUpdatesState(handle: Long) = UpdatesStateDto(1, 0, 1, 0) - override fun sendTextMessage( - handle: Long, - chatId: Long, - text: String, - replyToMsgId: Int, - entitiesJson: String?, - topMsgId: Int, - webpageUrl: String?, - randomId: Long, - ) = sampleMessage(chatId, 9) - } - val client = client(native, StandardTestDispatcher(testScheduler)) { sidecar++ } - try { - assertTrue(client.connect() is Outcome.Ok) - assertEquals(1, connects) - assertEquals(1, sidecar) - val history = async { client.getHistory(PeerId(1)) } - val send = async { client.sendText(PeerId(1), "hi") } - val chats = async { client.getChats() } - val state = async { client.getUpdatesState() } - assertTrue(history.await() is Outcome.Ok) - assertTrue(send.await() is Outcome.Ok) - assertTrue(chats.await() is Outcome.Ok) - assertTrue(state.await() is Outcome.Ok) - assertEquals(1, connects) - assertEquals(1, sidecar) - } finally { - client.close() + fun transportFailureInvalidatesHandleAndReconnects() = + runTest { + var connects = 0 + var chats = 0 + val native = + object : RecordingNative() { + override fun connect(handle: Long) { + connects++ + } + + override fun getChats(handle: Long): List { + chats++ + if (chats == 1) throw IllegalStateException("connection closed") + return emptyList() + } + + override fun getHistory( + handle: Long, + chatId: Long, + limit: Int, + ): List = emptyList() + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + assertEquals(1, connects) + assertTrue(client.getChats() is Outcome.Err) + assertTrue(client.getHistory(PeerId(1)) is Outcome.Ok) + assertEquals(2, connects) + assertEquals(1, native.created) + } finally { + client.close() + } } - } @Test - fun transportFailureInvalidatesHandleAndReconnects() = runTest { - var connects = 0 - var chats = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) { - connects++ - } - override fun getChats(handle: Long): List { - chats++ - if (chats == 1) throw IllegalStateException("connection closed") - return emptyList() + fun dcMigrationDoesNotClearTheWarmHandle() = + runTest { + var connects = 0 + var history = 0 + val native = + object : RecordingNative() { + override fun connect(handle: Long) { + connects++ + } + + override fun getHistory( + handle: Long, + chatId: Long, + limit: Int, + ): List { + history++ + if (history == 1) throw MtprotoException.Message("RPC 303: USER_MIGRATE_2") + return emptyList() + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + assertTrue(client.getHistory(PeerId(1)) is Outcome.Err) + assertTrue(client.getHistory(PeerId(1)) is Outcome.Ok) + assertEquals(1, connects) + } finally { + client.close() } - override fun getHistory(handle: Long, chatId: Long, limit: Int): List = emptyList() } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.connect() is Outcome.Ok) - assertEquals(1, connects) - assertTrue(client.getChats() is Outcome.Err) - assertTrue(client.getHistory(PeerId(1)) is Outcome.Ok) - assertEquals(2, connects) - assertEquals(1, native.created) - } finally { - client.close() - } - } @Test - fun dcMigrationDoesNotClearTheWarmHandle() = runTest { - var connects = 0 - var history = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) { - connects++ - } - override fun getHistory(handle: Long, chatId: Long, limit: Int): List { - history++ - if (history == 1) throw MtprotoException.Message("RPC 303: USER_MIGRATE_2") - return emptyList() - } - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.connect() is Outcome.Ok) - assertTrue(client.getHistory(PeerId(1)) is Outcome.Err) - assertTrue(client.getHistory(PeerId(1)) is Outcome.Ok) - assertEquals(1, connects) - } finally { + fun closeStopsUpdateDraining() = + runTest { + var drains = 0 + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long) = true + + override fun drainUpdates(handle: Long): List { + drains++ + return emptyList() + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + client.connect() + val collector = backgroundScope.launch { client.updates().collect() } + testScheduler.advanceTimeBy(2_000) + assertTrue(drains > 0) + val beforeClose = drains client.close() + testScheduler.advanceTimeBy(30_000) + assertEquals(beforeClose, drains) + collector.cancel() } - } - - @Test - fun closeStopsUpdateDraining() = runTest { - var drains = 0 - val native = object : RecordingNative() { - override fun drainUpdates(handle: Long): List { - drains++ - return emptyList() - } - } - val client = client(native, StandardTestDispatcher(testScheduler)) - client.connect() - val collector = backgroundScope.launch { client.updates().collect() } - testScheduler.advanceTimeBy(2_000) - assertTrue(drains > 0) - val beforeClose = drains - client.close() - testScheduler.advanceTimeBy(30_000) - assertEquals(beforeClose, drains) - collector.cancel() - } @Test fun unknownRpcFailureLogsOnlyValidatedIdentifier() { val raw = "RPC 400: PASSKEY_TEST_ERROR" - val error = org.monogram.core.common.telegram.TelegramError.parse(raw) + val error = + org.monogram.core.common.telegram.TelegramError + .parse(raw) assertTrue(nativeFailureLogLine(error, raw).contains("rpc_code=400 rpc_type=PASSKEY_TEST_ERROR")) listOf( "$raw payload=private-credential", "RPC 400: private-credential", "RPC 400: PASSKEY_TEST_ERROR\nprivate-credential", ).forEach { invalid -> - val parsed = org.monogram.core.common.telegram.TelegramError.parse(invalid) + val parsed = + org.monogram.core.common.telegram.TelegramError + .parse(invalid) val line = nativeFailureLogLine(parsed, invalid) assertTrue(!line.contains("rpc_type=")) assertTrue(!line.contains("private-credential")) @@ -724,15 +859,18 @@ class BridgedMtprotoClientLifecycleTest { @Test fun passkeyLocalFailuresLogOnlyFixedLabels() { - val reasons = mapOf( - "invalid passkey credential" to "passkey_credential_invalid", - "invalid passkey user handle" to "passkey_user_handle_invalid", - "stale passkey login" to "passkey_login_stale", - "missing passkey login key" to "passkey_login_key_missing", - ) + val reasons = + mapOf( + "invalid passkey credential" to "passkey_credential_invalid", + "invalid passkey user handle" to "passkey_user_handle_invalid", + "stale passkey login" to "passkey_login_stale", + "missing passkey login key" to "passkey_login_key_missing", + ) reasons.forEach { (reason, label) -> val raw = "$reason payload=private-credential" - val error = org.monogram.core.common.telegram.TelegramError.parse(raw) + val error = + org.monogram.core.common.telegram.TelegramError + .parse(raw) val line = nativeFailureLogLine(error, raw) assertTrue(line.contains("local=$label")) assertTrue(!line.contains("private-credential")) @@ -741,9 +879,10 @@ class BridgedMtprotoClientLifecycleTest { @Test fun unknownNativeFailureDoesNotLeakPayloadIntoLogs() { - val error = org.monogram.core.common.telegram.TelegramError.parse( - "decode failed via body=private-message password=secret snapshot=private-session", - ) + val error = + org.monogram.core.common.telegram.TelegramError.parse( + "decode failed via body=private-message password=secret snapshot=private-session", + ) val line = nativeFailureLogLine(error) assertTrue(!line.contains("private", ignoreCase = true)) assertTrue(!line.contains("secret", ignoreCase = true)) @@ -751,14 +890,18 @@ class BridgedMtprotoClientLifecycleTest { @Test fun degradedSessionKeepsRpcCauseWithoutRawPayload() { - val error = org.monogram.core.common.telegram.TelegramError.parse("RPC 406: AUTH_KEY_DUPLICATED") + val error = + org.monogram.core.common.telegram.TelegramError + .parse("RPC 406: AUTH_KEY_DUPLICATED") assertTrue(nativeFailureLogLine(error).contains("cause=rpc_rejected")) } @Test fun localPeerFailureHasSafeCauseWithoutPeerIdentifier() { val raw = "unknown peer 123456789" - val error = org.monogram.core.common.telegram.TelegramError.parse(raw) + val error = + org.monogram.core.common.telegram.TelegramError + .parse(raw) val line = nativeFailureLogLine(error, raw) assertTrue(line.contains("cause=peer_resolution")) assertTrue(!line.contains("123456789")) @@ -767,7 +910,9 @@ class BridgedMtprotoClientLifecycleTest { @Test fun badMessageLogsOnlyNumericProtocolCode() { val raw = "bad_msg_notification 32 recv=123 body=private-message password=secret" - val error = org.monogram.core.common.telegram.TelegramError.parse(raw) + val error = + org.monogram.core.common.telegram.TelegramError + .parse(raw) val line = nativeFailureLogLine(error, raw) assertTrue(line.contains("cause=bad_message bad_msg_code=32")) assertTrue(!line.contains("private")) @@ -778,7 +923,9 @@ class BridgedMtprotoClientLifecycleTest { @Test fun nativeSignOutLogsItsReasonAndNotAFakeServerRejection() { val raw = "session invalidated: AUTH_KEY_DUPLICATED" - val error = org.monogram.core.common.telegram.TelegramError.parse(raw) + val error = + org.monogram.core.common.telegram.TelegramError + .parse(raw) val line = nativeFailureLogLine(error, raw) assertTrue(line.contains("type=AUTH_KEY_DUPLICATED")) assertTrue(line.contains("cause=session_invalidated reason=AUTH_KEY_DUPLICATED")) @@ -787,197 +934,238 @@ class BridgedMtprotoClientLifecycleTest { } @Test - fun revokedHomeSessionStopsUpdateDraining() = runTest { - var drains = 0 - val native = object : RecordingNative() { - override fun drainUpdates(handle: Long): List { - drains++ - throw MtprotoException.Message("RPC 406: AUTH_KEY_DUPLICATED") - } - } - val client = client(native, StandardTestDispatcher(testScheduler)) - client.connect() - val collector = backgroundScope.launch { client.updates().collect() } - testScheduler.advanceTimeBy(60_000) - assertEquals(1, drains) - client.close() - collector.cancel() - } - - @Test - fun socketCloseReconnectsUpdateDrainingWithoutAnotherRequest() = runTest { - var drains = 0 - var connects = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) { - connects++ - } - override fun drainUpdates(handle: Long): List { - drains++ - if (drains == 1) throw MtprotoException.Message("connection closed recv=0") - return listOf(newMessageEvent(9)) - } - } - val client = client(native, StandardTestDispatcher(testScheduler)) - val received = mutableListOf() - val collector = backgroundScope.launch { - client.updates().filterIsInstance().collect { - received += it.message.id.id - } - } - try { - assertTrue(client.connect() is Outcome.Ok) - testScheduler.runCurrent() - testScheduler.advanceTimeBy(3_999) - testScheduler.runCurrent() - assertEquals(1, connects) + fun revokedHomeSessionStopsUpdateDraining() = + runTest { + var drains = 0 + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long) = true + + override fun drainUpdates(handle: Long): List { + drains++ + throw MtprotoException.Message("RPC 406: AUTH_KEY_DUPLICATED") + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + client.connect() + val collector = backgroundScope.launch { client.updates().collect() } + testScheduler.advanceTimeBy(60_000) assertEquals(1, drains) - testScheduler.advanceTimeBy(1) - testScheduler.runCurrent() - assertEquals(2, connects) - assertEquals(2, drains) - assertEquals(1, native.created) - assertEquals(listOf(9), received) - } finally { - collector.cancel() client.close() + collector.cancel() } - } @Test - fun failedUpdateReconnectBacksOffUntilConnectionRecovers() = runTest { - var drains = 0 - var connects = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) { - connects++ - if (connects in 2..3) throw MtprotoException.Message("connection closed") - } - - override fun drainUpdates(handle: Long): List { - drains++ - if (drains == 1) throw MtprotoException.Message("connection closed") - return emptyList() - } - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.connect() is Outcome.Ok) - testScheduler.runCurrent() - for ((elapsed, expected) in listOf(4_000L to 2, 8_000L to 3, 16_000L to 4)) { - testScheduler.advanceTimeBy(elapsed - 1) + fun socketCloseReconnectsUpdateDrainingWithoutAnotherRequest() = + runTest { + var drains = 0 + var connects = 0 + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long) = true + + override fun connect(handle: Long) { + connects++ + } + + override fun drainUpdates(handle: Long): List { + drains++ + if (drains == 1) throw MtprotoException.Message("connection closed recv=0") + return listOf(newMessageEvent(9)) + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + val received = mutableListOf() + val collector = + backgroundScope.launch { + client.updates().filterIsInstance().collect { + received += it.message.id.id + } + } + try { + assertTrue(client.connect() is Outcome.Ok) testScheduler.runCurrent() - assertEquals(expected - 1, connects) + testScheduler.advanceTimeBy(3_999) + testScheduler.runCurrent() + assertEquals(1, connects) assertEquals(1, drains) testScheduler.advanceTimeBy(1) testScheduler.runCurrent() - assertEquals(expected, connects) + assertEquals(2, connects) + assertEquals(2, drains) + assertEquals(1, native.created) + assertEquals(listOf(9), received) + } finally { + collector.cancel() + client.close() } - assertEquals(2, drains) - assertEquals(1, native.created) - } finally { - client.close() } - } @Test - fun hibernateStopsPendingUpdateReconnect() = runTest { - var connects = 0 - val native = object : RecordingNative() { - override fun connect(handle: Long) { - connects++ + fun failedUpdateReconnectBacksOffUntilConnectionRecovers() = + runTest { + var drains = 0 + var connects = 0 + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long) = true + + override fun connect(handle: Long) { + connects++ + if (connects in 2..3) throw MtprotoException.Message("connection closed") + } + + override fun drainUpdates(handle: Long): List { + drains++ + if (drains == 1) throw MtprotoException.Message("connection closed") + return emptyList() + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + testScheduler.runCurrent() + for ((elapsed, expected) in listOf(4_000L to 2, 8_000L to 3, 16_000L to 4)) { + testScheduler.advanceTimeBy(elapsed - 1) + testScheduler.runCurrent() + assertEquals(expected - 1, connects) + assertEquals(1, drains) + testScheduler.advanceTimeBy(1) + testScheduler.runCurrent() + assertEquals(expected, connects) + } + assertEquals(2, drains) + assertEquals(1, native.created) + } finally { + client.close() } - - override fun drainUpdates(handle: Long): List = - throw MtprotoException.Message("connection closed") - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.connect() is Outcome.Ok) - testScheduler.runCurrent() - client.hibernate() - testScheduler.advanceTimeBy(60_000) - testScheduler.runCurrent() - assertEquals(1, connects) - assertEquals(1, native.created) - assertEquals(listOf(1L), native.destroyed) - } finally { - client.close() } - } @Test - fun reconnectWakesUpdateDrainOnTheSameHandle() = runTest { - var starts = 0 - var drains = 0 - val received = mutableListOf() - val native = object : RecordingNative() { - override fun isAuthorized(handle: Long) = true - - override fun startUpdates(handle: Long) { - starts++ - } - - override fun drainUpdates(handle: Long): List { - drains++ - if (drains == 1) { - throw MtprotoException.Message("RPC 500: transport closed") + fun hibernateStopsPendingUpdateReconnect() = + runTest { + var connects = 0 + val native = + object : RecordingNative() { + override fun connect(handle: Long) { + connects++ + } + + override fun drainUpdates(handle: Long): List = + throw MtprotoException.Message("connection closed") } - return listOf(newMessageEvent(9)) - } - } - val client = client(native, StandardTestDispatcher(testScheduler)) - val collector = backgroundScope.launch { - client.updates().filterIsInstance().collect { - received += it.message.id.id + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + testScheduler.runCurrent() + client.hibernate() + testScheduler.advanceTimeBy(60_000) + testScheduler.runCurrent() + assertEquals(1, connects) + assertEquals(1, native.created) + assertEquals(listOf(1L), native.destroyed) + } finally { + client.close() } } - try { - assertTrue(client.connect() is Outcome.Ok) - testScheduler.runCurrent() - testScheduler.advanceTimeBy(1_000) - testScheduler.runCurrent() - assertEquals(1, drains) - assertEquals(1, starts) - - assertTrue(client.connect() is Outcome.Ok) - testScheduler.advanceTimeBy(3_000) - testScheduler.runCurrent() - - assertEquals(1, native.created) - assertEquals(1, starts) - assertEquals(listOf(9), received) - } finally { - collector.cancel() - client.close() - } - } @Test - fun rpcErrorsKeepTypedClassification() = runTest { - for ((raw, kind) in listOf( - "RPC 401: SESSION_REVOKED" to org.monogram.core.common.telegram.TelegramError.Kind.Session, - "RPC 303: FILE_MIGRATE_2" to org.monogram.core.common.telegram.TelegramError.Kind.SeeOther, - "RPC 420: FLOOD_WAIT_60" to org.monogram.core.common.telegram.TelegramError.Kind.Flood, - )) { - val native = object : RecordingNative() { - override fun getHistory(handle: Long, chatId: Long, limit: Int): List = - throw MtprotoException.Message(raw) - } + fun reconnectWakesUpdateDrainOnTheSameHandle() = + runTest { + var starts = 0 + var drains = 0 + val received = mutableListOf() + val native = + object : RecordingNative() { + override fun isAuthorized(handle: Long) = true + + override fun startUpdates(handle: Long) { + starts++ + } + + override fun drainUpdates(handle: Long): List { + drains++ + if (drains == 1) { + throw MtprotoException.Message("RPC 500: transport closed") + } + return listOf(newMessageEvent(9)) + } + } val client = client(native, StandardTestDispatcher(testScheduler)) + val collector = + backgroundScope.launch { + client.updates().filterIsInstance().collect { + received += it.message.id.id + } + } try { - val outcome = client.getHistory(PeerId(1)) - assertTrue(outcome is Outcome.Err) - assertEquals(kind, (outcome as Outcome.Err).telegramError.kind) + assertTrue(client.connect() is Outcome.Ok) + testScheduler.runCurrent() + testScheduler.advanceTimeBy(1_000) + testScheduler.runCurrent() + assertEquals(1, drains) + assertEquals(1, starts) + + assertTrue(client.connect() is Outcome.Ok) + testScheduler.advanceTimeBy(3_000) + testScheduler.runCurrent() + + assertEquals(1, native.created) + assertEquals(1, starts) + assertEquals(listOf(9), received) } finally { + collector.cancel() client.close() } } - } + + @Test + fun rpcErrorsKeepTypedClassification() = + runTest { + for ((raw, kind) in listOf( + "RPC 401: SESSION_REVOKED" to org.monogram.core.common.telegram.TelegramError.Kind.Session, + "RPC 303: FILE_MIGRATE_2" to org.monogram.core.common.telegram.TelegramError.Kind.SeeOther, + "RPC 420: FLOOD_WAIT_60" to org.monogram.core.common.telegram.TelegramError.Kind.Flood, + )) { + val native = + object : RecordingNative() { + override fun getHistory( + handle: Long, + chatId: Long, + limit: Int, + ): List = throw MtprotoException.Message(raw) + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + val outcome = client.getHistory(PeerId(1)) + assertTrue(outcome is Outcome.Err) + assertEquals(kind, (outcome as Outcome.Err).telegramError.kind) + } finally { + client.close() + } + } + } private class DurableNative : RecordingNative() { val drainResults = ArrayDeque() var drainFailure: Exception? = null + var startFailure: Exception? = null + var configFailure: Exception? = null + var ackFailure: Exception? = null + var starts = 0 + + override fun startUpdates(handle: Long) { + starts++ + startFailure?.let { throw it } + } + + override fun updateLazySyncConfig( + handle: Long, + lazy: Boolean, + exceptions: List, + ) { + configFailure?.let { throw it } + } /** Like the native journal: delivered batches stay until acknowledged. */ val unacked = mutableListOf() @@ -985,11 +1173,21 @@ class BridgedMtprotoClientLifecycleTest { val calls = mutableListOf() override fun isAuthorized(handle: Long) = true - override fun getHistory(handle: Long, chatId: Long, limit: Int): List = - emptyList() + + override fun getChats(handle: Long) = emptyList() + + override fun getHistory( + handle: Long, + chatId: Long, + limit: Int, + ): List = emptyList() + override fun drainUpdateBatches(handle: Long): UpdateDrainDto { calls += "drain" - drainFailure?.let { failure -> drainFailure = null; throw failure } + drainFailure?.let { failure -> + drainFailure = null + throw failure + } val result = drainResults.removeFirstOrNull() ?: UpdateDrainDto(emptyList(), emptyList(), false) unacked += result.batches @@ -1002,19 +1200,28 @@ class BridgedMtprotoClientLifecycleTest { return unacked.toList() } - override fun acknowledgeUpdateBatch(handle: Long, accountGeneration: Long, batchId: Long) { + override fun acknowledgeUpdateBatch( + handle: Long, + accountGeneration: Long, + batchId: Long, + ) { calls += "ack:$accountGeneration:$batchId" + ackFailure?.let { failure -> + ackFailure = null + throw failure + } unacked.removeAll { it.accountGeneration == accountGeneration && it.batchId == batchId } } } - private fun durableBatch(id: Long) = UpdateBatchDto( - accountGeneration = 7L, - batchId = id, - events = listOf(newMessageEvent(id.toInt())), - cursorAfter = UpdatesStateDto(id.toInt(), 0, 1, 1), - channelPtsAfter = emptyList(), - ) + private fun durableBatch(id: Long) = + UpdateBatchDto( + accountGeneration = 7L, + batchId = id, + events = listOf(newMessageEvent(id.toInt())), + cursorAfter = UpdatesStateDto(id.toInt(), 0, 1, 1), + channelPtsAfter = emptyList(), + ) private suspend fun kotlinx.coroutines.test.TestScope.settle(ms: Long = 300) { testScheduler.advanceTimeBy(ms) @@ -1022,610 +1229,1022 @@ class BridgedMtprotoClientLifecycleTest { } @Test - fun catchupCompletionWaitsForDurableCommit() = runTest { - val native = DurableNative() - native.drainResults += UpdateDrainDto( - listOf(durableBatch(1)), - listOf(UpdateEventDto.SyncState(true)), - false - ) - native.drainResults += UpdateDrainDto( - listOf(durableBatch(2)), - listOf(UpdateEventDto.SyncState(false)), - false - ) - val client = client(native, StandardTestDispatcher(testScheduler)) - val release = CompletableDeferred() - val committing = CompletableDeferred() - val statuses = mutableListOf() - backgroundScope.launch(StandardTestDispatcher(testScheduler)) { - client.updates().filterIsInstance() - .collect { statuses += it.isSyncing } - } - client.installDurableUpdateSink { batch -> - if (batch.batchId == 2L) { + fun failedUpdateStartupDoesNotLatchSyncingAndCanRetry() = + runTest { + val native = DurableNative().apply { + startFailure = MtprotoException.Message("RPC 500: start failed") + } + val client = client(native, StandardTestDispatcher(testScheduler)) + val statuses = mutableListOf() + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + client + .updates() + .filterIsInstance() + .collect { statuses += it.isSyncing } + } + val committing = CompletableDeferred() + val release = CompletableDeferred() + client.installDurableUpdateSink { + committing.complete(Unit) + release.await() + true + } + try { + testScheduler.runCurrent() + assertTrue(client.getChats() is Outcome.Ok) + settle(100) + assertFalse("No update loop started successfully", statuses.last()) + assertTrue(native.calls.none { it == "drain" }) + native.startFailure = null + native.drainResults += + UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(false)), + false, + ) + assertTrue(client.connect() is Outcome.Ok) + settle(100) + assertTrue(committing.isCompleted) + assertTrue(statuses.last()) + assertTrue(native.calls.none { it.startsWith("ack") }) + release.complete(Unit) + settle(100) + assertFalse(statuses.last()) + assertEquals(1, native.calls.count { it.startsWith("ack") }) + } finally { + release.complete(Unit) + client.close() + } + } + + @Test + fun failedUpdateConfigurationCannotDrainBeforeStartupPublication() = + runTest { + val native = + DurableNative().apply { + configFailure = MtprotoException.Message("RPC 500: config failed") + drainResults += + UpdateDrainDto( + emptyList(), + listOf(UpdateEventDto.SyncState(false)), + false, + ) + } + val client = client(native, StandardTestDispatcher(testScheduler)) + val statuses = mutableListOf() + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + client + .updates() + .filterIsInstance() + .collect { statuses += it.isSyncing } + } + try { + testScheduler.runCurrent() + assertTrue(client.connect() is Outcome.Ok) + settle(100) + assertFalse(statuses.last()) + assertTrue(native.calls.none { it == "drain" }) + native.configFailure = null + settle(4_000) + assertTrue(native.calls.contains("drain")) + assertFalse(statuses.last()) + } finally { + client.close() + } + } + + @Test + fun automaticUpdateStartupRetryDegradesRevokedSession() = + runTest { + val native = + DurableNative().apply { + configFailure = MtprotoException.Message("RPC 500: config failed") + } + val client = client(native, StandardTestDispatcher(testScheduler)) + var lost = false + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + client.sessionLost().collect { lost = true } + } + try { + testScheduler.runCurrent() + assertTrue(client.connect() is Outcome.Ok) + settle(100) + native.configFailure = MtprotoException.Message("RPC 401: AUTH_KEY_UNREGISTERED") + settle(4_000) + assertTrue(lost) + assertTrue(client.getChats() is Outcome.Err) + assertTrue(native.calls.none { it == "drain" }) + } finally { + client.close() + } + } + + @Test + fun failedUpdateStartupBacksOffAndCloseCancelsRetry() = + runTest { + val native = + DurableNative().apply { + startFailure = MtprotoException.Message("RPC 500: start failed") + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.connect() is Outcome.Ok) + settle(3_999) + assertEquals(1, native.starts) + settle(1) + assertEquals(2, native.starts) + settle(7_999) + assertEquals(2, native.starts) + client.close() + native.startFailure = null + settle(60_000) + assertEquals(2, native.starts) + assertTrue(native.calls.none { it == "drain" }) + } finally { + client.close() + } + } + + @Test + fun catchupCompletionWaitsForDurableCommit() = + runTest { + val native = DurableNative() + native.drainResults += + UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(true)), + false, + ) + native.drainResults += + UpdateDrainDto( + listOf(durableBatch(2)), + listOf(UpdateEventDto.SyncState(false)), + false, + ) + val client = client(native, StandardTestDispatcher(testScheduler)) + val release = CompletableDeferred() + val committing = CompletableDeferred() + val statuses = mutableListOf() + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + client + .updates() + .filterIsInstance() + .collect { statuses += it.isSyncing } + } + client.installDurableUpdateSink { batch -> + if (batch.batchId == 2L) { + committing.complete(Unit) + release.await() + } + true + } + try { + testScheduler.runCurrent() + assertTrue(client.connect() is Outcome.Ok) + settle(100) + assertTrue(committing.isCompleted) + assertTrue( + "Final page is not complete while its Room commit is suspended", + statuses.last(), + ) + assertTrue(native.calls.none { it == "ack:7:2" }) + release.complete(Unit) + settle(100) + assertEquals(false, statuses.last()) + assertTrue(native.calls.contains("ack:7:2")) + } finally { + release.complete(Unit) + client.close() + } + } + + @Test + fun lateSubscriberReceivesCurrentCatchupStatusWithoutReplayingMessages() = + runTest { + val native = DurableNative() + native.drainResults += + UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(true)), + false, + ) + val client = client(native, StandardTestDispatcher(testScheduler)) + client.installDurableUpdateSink { true } + val received = mutableListOf() + try { + assertTrue(client.connect() is Outcome.Ok) + settle(100) + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + client.updates().collect { received += it } + } + testScheduler.runCurrent() + assertEquals(listOf(MtprotoUpdate.SyncState(true)), received) + } finally { + client.close() + } + } + + @Test + fun failedFinalCommitKeepsCatchupActiveUntilReplaySucceeds() = + runTest { + val native = DurableNative() + native.drainResults += + UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(false)), + false, + ) + val client = client(native, StandardTestDispatcher(testScheduler)) + var allowCommit = false + var attempts = 0 + val received = mutableListOf() + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + client.updates().collect { received += it } + } + client.installDurableUpdateSink { + attempts++ + allowCommit + } + try { + testScheduler.runCurrent() + assertTrue(client.connect() is Outcome.Ok) + settle(100) + assertTrue(attempts > 1) + assertTrue(received.filterIsInstance().last().isSyncing) + assertEquals(1, native.unacked.size) + assertTrue(native.calls.none { it.startsWith("ack") }) + assertTrue(received.filterIsInstance().isEmpty()) + allowCommit = true + settle(100) + assertEquals( + false, + received.filterIsInstance().last().isSyncing, + ) + assertEquals(1, native.calls.count { it.startsWith("ack") }) + assertEquals(1, received.filterIsInstance().size) + assertTrue(native.unacked.isEmpty()) + } finally { + client.close() + } + } + + @Test + fun closingDuringDurableCommitCannotAcknowledgeDestroyedHandle() = + runTest { + val native = DurableNative() + native.drainResults += + UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(false)), + false, + ) + val client = client(native, StandardTestDispatcher(testScheduler)) + val committing = CompletableDeferred() + val release = CompletableDeferred() + client.installDurableUpdateSink { committing.complete(Unit) - release.await() + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { release.await() } + true + } + try { + assertTrue(client.connect() is Outcome.Ok) + settle(50) + assertTrue(committing.isCompleted) + client.close() + release.complete(Unit) + testScheduler.runCurrent() + assertTrue(native.calls.none { it.startsWith("ack") }) + assertEquals(1, native.unacked.size) + } finally { + release.complete(Unit) + client.close() } - true } - try { - testScheduler.runCurrent() - assertTrue(client.connect() is Outcome.Ok) - settle(100) - assertTrue(committing.isCompleted) - assertTrue( - "Final page is not complete while its Room commit is suspended", - statuses.last() - ) - assertTrue(native.calls.none { it == "ack:7:2" }) - release.complete(Unit) - settle(100) - assertEquals(false, statuses.last()) - assertTrue(native.calls.contains("ack:7:2")) - } finally { - release.complete(Unit) - client.close() + + @Test + fun committedCatchupPagesContinueWithoutAnArtificialSleepAndYieldToHistory() = + runTest { + val native = DurableNative() + repeat(16) { index -> + native.drainResults += + UpdateDrainDto( + listOf( + durableBatch(index + 1L).copy( + events = List(64) { newMessageEvent(index * 64 + it + 1) }, + cursorAfter = UpdatesStateDto((index + 1) * 64, 0, 1, 1), + ), + ), + listOf(UpdateEventDto.SyncState(index != 15)), + false, + ) + } + val client = client(native, StandardTestDispatcher(testScheduler)) + val publishedAt = mutableListOf() + val acceptedIds = mutableListOf() + var interactiveDrainCount = Int.MAX_VALUE + client.installDurableUpdateSink { batch -> + publishedAt += testScheduler.currentTime + acceptedIds += + batch.updates + .filterIsInstance() + .map { it.message.id.id } + if (batch.batchId == 1L) { + backgroundScope.launch(StandardTestDispatcher(testScheduler)) { + assertTrue(client.getHistory(PeerId(42)) is Outcome.Ok) + interactiveDrainCount = native.calls.count { it == "drain" } + } + } + true + } + try { + assertTrue(client.connect() is Outcome.Ok) + settle(500) + assertEquals(16, publishedAt.size) + assertEquals((1..1024).toList(), acceptedIds) + assertEquals(1, native.maxUnacked) + assertEquals(16, native.calls.count { it.startsWith("ack") }) + assertTrue( + "Interactive history must run before the backlog finishes: drain=$interactiveDrainCount calls=${native.calls}", + interactiveDrainCount < 16, + ) + assertEquals( + "Ready slices must not incur 15 fixed 25ms sleeps", + 0L, + publishedAt.last() - publishedAt.first(), + ) + val drainsBeforeIdle = native.calls.count { it == "drain" } + settle(100) + val idleDrains = native.calls.count { it == "drain" } - drainsBeforeIdle + assertTrue("Idle drains retain pacing: $idleDrains", idleDrains in 3..5) + } finally { + client.close() + } } - } @Test - fun lateSubscriberReceivesCurrentCatchupStatusWithoutReplayingMessages() = runTest { - val native = DurableNative() - native.drainResults += UpdateDrainDto( - listOf(durableBatch(1)), - listOf(UpdateEventDto.SyncState(true)), - false - ) - val client = client(native, StandardTestDispatcher(testScheduler)) - client.installDurableUpdateSink { true } - val received = mutableListOf() - try { - assertTrue(client.connect() is Outcome.Ok) - settle(100) + fun reconnectMarksCatchupActiveUntilRecoveredPageIsCommitted() = + runTest { + val native = DurableNative() + native.drainResults += + UpdateDrainDto( + emptyList(), + listOf(UpdateEventDto.SyncState(false)), + false, + ) + val client = client(native, StandardTestDispatcher(testScheduler)) + val release = CompletableDeferred() + val committing = CompletableDeferred() + val statuses = mutableListOf() backgroundScope.launch(StandardTestDispatcher(testScheduler)) { - client.updates().collect { received += it } + client + .updates() + .filterIsInstance() + .collect { statuses += it.isSyncing } + } + client.installDurableUpdateSink { + committing.complete(Unit) + release.await() + true + } + try { + testScheduler.runCurrent() + assertTrue(client.connect() is Outcome.Ok) + settle(100) + assertEquals(false, statuses.last()) + native.drainFailure = MtprotoException.Message("connection closed") + native.drainResults += + UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(false)), + false, + ) + settle(5000) + assertTrue(committing.isCompleted) + assertEquals(true, statuses.last()) + release.complete(Unit) + settle(100) + assertEquals(false, statuses.last()) + assertEquals(1, native.calls.count { it.startsWith("ack") }) + } finally { + release.complete(Unit) + client.close() } - testScheduler.runCurrent() - assertEquals(listOf(MtprotoUpdate.SyncState(true)), received) - } finally { - client.close() } - } @Test - fun failedFinalCommitKeepsCatchupActiveUntilReplaySucceeds() = runTest { - val native = DurableNative() - native.drainResults += UpdateDrainDto( - listOf(durableBatch(1)), - listOf(UpdateEventDto.SyncState(false)), - false - ) - val client = client(native, StandardTestDispatcher(testScheduler)) - var allowCommit = false - var attempts = 0 - val received = mutableListOf() - backgroundScope.launch(StandardTestDispatcher(testScheduler)) { - client.updates().collect { received += it } - } - client.installDurableUpdateSink { - attempts++ - allowCommit - } - try { - testScheduler.runCurrent() - assertTrue(client.connect() is Outcome.Ok) - settle(100) - assertTrue(attempts > 1) - assertTrue(received.filterIsInstance().last().isSyncing) - assertEquals(1, native.unacked.size) - assertTrue(native.calls.none { it.startsWith("ack") }) - allowCommit = true - settle(100) - assertEquals( - false, - received.filterIsInstance().last().isSyncing - ) - assertEquals(1, native.calls.count { it.startsWith("ack") }) - assertEquals(1, received.filterIsInstance().size) - assertTrue(native.unacked.isEmpty()) - } finally { - client.close() + fun retiredClientCompletionCannotClearReplacementCatchup() = + runTest { + val oldNative = + DurableNative().apply { + drainResults += + UpdateDrainDto( + listOf(durableBatch(1)), + listOf(UpdateEventDto.SyncState(false)), + false, + ) + } + val nextNative = + DurableNative().apply { + drainResults += + UpdateDrainDto( + listOf(durableBatch(2).copy(accountGeneration = 8)), + listOf(UpdateEventDto.SyncState(false)), + false, + ) + } + val dispatcher = StandardTestDispatcher(testScheduler) + val old = client(oldNative, dispatcher) + val replacement = client(nextNative, dispatcher) + val oldCommit = CompletableDeferred() + val nextCommit = CompletableDeferred() + val statuses = mutableListOf() + old.installDurableUpdateSink { + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { oldCommit.await() } + true + } + replacement.installDurableUpdateSink { + nextCommit.await() + true + } + backgroundScope.launch(dispatcher) { + replacement + .updates() + .filterIsInstance() + .collect { statuses += it.isSyncing } + } + try { + old.connect() + settle(50) + old.close() + replacement.connect() + settle(50) + assertEquals(true, statuses.last()) + oldCommit.complete(Unit) + settle(50) + assertEquals(true, statuses.last()) + assertTrue(oldNative.calls.none { it.startsWith("ack") }) + nextCommit.complete(Unit) + settle(50) + assertEquals(false, statuses.last()) + assertEquals(listOf("ack:8:2"), nextNative.calls.filter { it.startsWith("ack") }) + } finally { + oldCommit.complete(Unit) + nextCommit.complete(Unit) + old.close() + replacement.close() + } } - } @Test - fun closingDuringDurableCommitCannotAcknowledgeDestroyedHandle() = runTest { - val native = DurableNative() - native.drainResults += UpdateDrainDto( - listOf(durableBatch(1)), - listOf(UpdateEventDto.SyncState(false)), - false - ) - val client = client(native, StandardTestDispatcher(testScheduler)) - val committing = CompletableDeferred() - val release = CompletableDeferred() - client.installDurableUpdateSink { - committing.complete(Unit) - kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { release.await() } - true - } - try { - assertTrue(client.connect() is Outcome.Ok) - settle(50) - assertTrue(committing.isCompleted) - client.close() - release.complete(Unit) - testScheduler.runCurrent() - assertTrue(native.calls.none { it.startsWith("ack") }) - assertEquals(1, native.unacked.size) - } finally { - release.complete(Unit) - client.close() + fun mixedDurableBatchReusesCommittedModelsAndDeliversBeforeAcknowledgement() = + runTest { + val native = DurableNative() + val batch = + durableBatch(1).copy( + events = + listOf( + newMessageEvent(10), + UpdateEventDto.MessageEdited(sampleMessage(42L, 9)), + UpdateEventDto.MessagesDeleted(42L, listOf(8)), + UpdateEventDto.ReadInbox(42L, 10, 0), + ), + ) + native.drainResults += + UpdateDrainDto( + listOf(batch), + listOf(UpdateEventDto.MessagesDeleted(null, listOf(7))), + false, + ) + val client = client(native, StandardTestDispatcher(testScheduler)) + val committed = mutableListOf() + val delivered = mutableListOf() + val release = CompletableDeferred() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + client.updates().collect { update -> + if (update is MtprotoUpdate.NewMessage || update is MtprotoUpdate.MessageEdited || + update is MtprotoUpdate.MessagesDeleted || update is MtprotoUpdate.ReadInbox + ) { + delivered += update + native.calls += "deliver:${delivered.size}" + } + } + } + client.installDurableUpdateSink { + committed += it + release.await() + native.calls += "commit:${it.batchId}" + true + } + try { + assertTrue(client.connect() is Outcome.Ok) + settle() + assertEquals(1, committed.size) + assertTrue(delivered.isEmpty()) + assertTrue(native.calls.none { it.startsWith("ack") }) + release.complete(Unit) + settle() + assertEquals( + listOf( + "commit:1", + "deliver:1", + "deliver:2", + "deliver:3", + "deliver:4", + "ack:7:1", + "deliver:5" + ), + native.calls.filter { + it.startsWith("commit") || it.startsWith("deliver") || it.startsWith( + "ack" + ) + }, + ) + val mapped = committed.single().updates + val incoming = delivered[0] as MtprotoUpdate.NewMessage + assertSame((mapped[0] as MtprotoUpdate.NewMessage).message, incoming.message) + assertEquals(7L, incoming.accountGeneration) + assertEquals(1L, incoming.acceptedOrder) + assertSame(mapped[1], delivered[1]) + assertSame(mapped[3], delivered[3]) + assertFalse((mapped[2] as MtprotoUpdate.MessagesDeleted).persisted) + assertTrue((delivered[2] as MtprotoUpdate.MessagesDeleted).persisted) + assertFalse((delivered[4] as MtprotoUpdate.MessagesDeleted).persisted) + assertEquals(PeerId(42), (delivered[2] as MtprotoUpdate.MessagesDeleted).chatId) + assertEquals(listOf(8), (delivered[2] as MtprotoUpdate.MessagesDeleted).messageIds) + } finally { + release.complete(Unit) + client.close() + } } - } @Test - fun committedCatchupPagesContinueWithoutAnArtificialSleepAndYieldToHistory() = runTest { - val native = DurableNative() - repeat(16) { index -> - native.drainResults += UpdateDrainDto( - listOf( - durableBatch(index + 1L).copy( - events = List(64) { newMessageEvent(index * 64 + it + 1) }, - cursorAfter = UpdatesStateDto((index + 1) * 64, 0, 1, 1), + fun rejectedOrThrowingCommitCannotDeliverOrAcknowledgeMixedBatch() = + runTest { + for (throws in listOf(false, true)) { + val native = DurableNative() + native.drainResults += + UpdateDrainDto( + listOf( + durableBatch(1).copy( + events = + listOf( + newMessageEvent(10), + UpdateEventDto.MessagesDeleted(42L, listOf(8)), + ), + ), + ), + emptyList(), + false, ) - ), - listOf(UpdateEventDto.SyncState(index != 15)), - false, - ) - } - val client = client(native, StandardTestDispatcher(testScheduler)) - val publishedAt = mutableListOf() - val acceptedIds = mutableListOf() - var interactiveDrainCount = Int.MAX_VALUE - client.installDurableUpdateSink { batch -> - publishedAt += testScheduler.currentTime - acceptedIds += batch.updates.filterIsInstance() - .map { it.message.id.id } - if (batch.batchId == 1L) { - backgroundScope.launch(StandardTestDispatcher(testScheduler)) { - assertTrue(client.getHistory(PeerId(42)) is Outcome.Ok) - interactiveDrainCount = native.calls.count { it == "drain" } + val client = client(native, StandardTestDispatcher(testScheduler)) + val delivered = mutableListOf() + var accept = false + var attempts = 0 + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + client.updates().collect { + if (it is MtprotoUpdate.NewMessage || it is MtprotoUpdate.MessagesDeleted) delivered += it + } + } + client.installDurableUpdateSink { + attempts++ + if (!accept && throws) error("commit failed") + accept + } + try { + assertTrue(client.connect() is Outcome.Ok) + settle(400) + assertTrue(attempts > 0) + assertTrue(delivered.isEmpty()) + assertTrue(native.calls.none { it.startsWith("ack") }) + assertEquals(1, native.unacked.size) + accept = true + settle(400) + assertEquals(2, delivered.size) + assertTrue((delivered[1] as MtprotoUpdate.MessagesDeleted).persisted) + assertEquals(listOf("ack:7:1"), native.calls.filter { it.startsWith("ack") }) + assertTrue(native.unacked.isEmpty()) + } finally { + client.close() } } - true - } - try { - assertTrue(client.connect() is Outcome.Ok) - settle(500) - assertEquals(16, publishedAt.size) - assertEquals((1..1024).toList(), acceptedIds) - assertEquals(1, native.maxUnacked) - assertEquals(16, native.calls.count { it.startsWith("ack") }) - assertTrue( - "Interactive history must run before the backlog finishes: drain=$interactiveDrainCount calls=${native.calls}", - interactiveDrainCount < 16 - ) - assertEquals( - "Ready slices must not incur 15 fixed 25ms sleeps", - 0L, - publishedAt.last() - publishedAt.first() - ) - val drainsBeforeIdle = native.calls.count { it == "drain" } - settle(100) - val idleDrains = native.calls.count { it == "drain" } - drainsBeforeIdle - assertTrue("Idle drains retain pacing: $idleDrains", idleDrains in 3..5) - } finally { - client.close() } - } @Test - fun reconnectMarksCatchupActiveUntilRecoveredPageIsCommitted() = runTest { - val native = DurableNative() - native.drainResults += UpdateDrainDto( - emptyList(), - listOf(UpdateEventDto.SyncState(false)), - false - ) - val client = client(native, StandardTestDispatcher(testScheduler)) - val release = CompletableDeferred() - val committing = CompletableDeferred() - val statuses = mutableListOf() - backgroundScope.launch(StandardTestDispatcher(testScheduler)) { - client.updates().filterIsInstance() - .collect { statuses += it.isSyncing } - } - client.installDurableUpdateSink { - committing.complete(Unit) - release.await() - true - } - try { - testScheduler.runCurrent() - assertTrue(client.connect() is Outcome.Ok) - settle(100) - assertEquals(false, statuses.last()) - native.drainFailure = MtprotoException.Message("connection closed") - native.drainResults += UpdateDrainDto( - listOf(durableBatch(1)), - listOf(UpdateEventDto.SyncState(false)), - false - ) - settle(5000) - assertTrue(committing.isCompleted) - assertEquals(true, statuses.last()) - release.complete(Unit) - settle(100) - assertEquals(false, statuses.last()) - assertEquals(1, native.calls.count { it.startsWith("ack") }) - } finally { - release.complete(Unit) - client.close() + fun acknowledgementFailureReplaysCommitWithoutDeliveringTwice() = + runTest { + val native = + DurableNative().apply { + ackFailure = IllegalStateException("ack failed") + drainResults += + UpdateDrainDto( + listOf( + durableBatch(1).copy( + events = + listOf( + newMessageEvent(10), + UpdateEventDto.MessagesDeleted(42L, listOf(8)), + ), + ), + ), + emptyList(), + false, + ) + } + val client = client(native, StandardTestDispatcher(testScheduler)) + var commits = 0 + val delivered = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + client.updates().collect { + if (it is MtprotoUpdate.NewMessage || it is MtprotoUpdate.MessagesDeleted) delivered += it + } + } + client.installDurableUpdateSink { + commits++ + true + } + try { + assertTrue(client.connect() is Outcome.Ok) + settle(400) + assertEquals(2, commits) + assertEquals(2, delivered.size) + assertTrue((delivered[1] as MtprotoUpdate.MessagesDeleted).persisted) + assertEquals( + listOf("ack:7:1", "ack:7:1"), + native.calls.filter { it.startsWith("ack") }) + assertTrue(native.unacked.isEmpty()) + } finally { + client.close() + } } - } @Test - fun retiredClientCompletionCannotClearReplacementCatchup() = runTest { - val oldNative = DurableNative().apply { - drainResults += UpdateDrainDto( - listOf(durableBatch(1)), - listOf(UpdateEventDto.SyncState(false)), - false - ) - } - val nextNative = DurableNative().apply { - drainResults += UpdateDrainDto( - listOf(durableBatch(2).copy(accountGeneration = 8)), - listOf(UpdateEventDto.SyncState(false)), - false - ) - } - val dispatcher = StandardTestDispatcher(testScheduler) - val old = client(oldNative, dispatcher) - val replacement = client(nextNative, dispatcher) - val oldCommit = CompletableDeferred() - val nextCommit = CompletableDeferred() - val statuses = mutableListOf() - old.installDurableUpdateSink { - kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { oldCommit.await() } - true - } - replacement.installDurableUpdateSink { nextCommit.await(); true } - backgroundScope.launch(dispatcher) { - replacement.updates().filterIsInstance() - .collect { statuses += it.isSyncing } - } - try { - old.connect() - settle(50) - old.close() - replacement.connect() - settle(50) - assertEquals(true, statuses.last()) - oldCommit.complete(Unit) - settle(50) - assertEquals(true, statuses.last()) - assertTrue(oldNative.calls.none { it.startsWith("ack") }) - nextCommit.complete(Unit) - settle(50) - assertEquals(false, statuses.last()) - assertEquals(listOf("ack:8:2"), nextNative.calls.filter { it.startsWith("ack") }) - } finally { - oldCommit.complete(Unit) - nextCommit.complete(Unit) - old.close() - replacement.close() + fun durableBatchIsCommittedBeforeItIsAcknowledged() = + runTest { + val native = DurableNative() + native.drainResults += UpdateDrainDto(listOf(durableBatch(1)), emptyList(), false) + val client = client(native, StandardTestDispatcher(testScheduler)) + val commits = mutableListOf() + client.installDurableUpdateSink { batch -> + native.calls += "commit:${batch.batchId}" + commits += batch + true + } + try { + assertTrue(client.connect() is Outcome.Ok) + settle() + val relevant = + native.calls.filter { it.startsWith("commit") || it.startsWith("ack") } + assertEquals(listOf("commit:1", "ack:7:1"), relevant) + assertEquals(1, commits.single().cursor?.pts) + assertTrue(commits.single().updates.single() is MtprotoUpdate.NewMessage) + } finally { + client.close() + } } - } @Test - fun durableBatchIsCommittedBeforeItIsAcknowledged() = runTest { - val native = DurableNative() - native.drainResults += UpdateDrainDto(listOf(durableBatch(1)), emptyList(), false) - val client = client(native, StandardTestDispatcher(testScheduler)) - val commits = mutableListOf() - client.installDurableUpdateSink { batch -> - native.calls += "commit:${batch.batchId}" - commits += batch - true - } - try { - assertTrue(client.connect() is Outcome.Ok) - settle() - val relevant = native.calls.filter { it.startsWith("commit") || it.startsWith("ack") } - assertEquals(listOf("commit:1", "ack:7:1"), relevant) - assertEquals(1, commits.single().cursor?.pts) - assertTrue(commits.single().updates.single() is MtprotoUpdate.NewMessage) - } finally { - client.close() + fun failedCommitStaysQueuedAndIsReplayedWithoutDuplicateAcknowledgement() = + runTest { + val native = DurableNative() + native.drainResults += UpdateDrainDto(listOf(durableBatch(1)), emptyList(), false) + val client = client(native, StandardTestDispatcher(testScheduler)) + var attempts = 0 + client.installDurableUpdateSink { _ -> + attempts++ + attempts > 1 + } + try { + assertTrue(client.connect() is Outcome.Ok) + settle(400) + assertEquals(2, attempts) + assertEquals(1, native.calls.count { it.startsWith("ack") }) + val firstAck = native.calls.indexOfFirst { it.startsWith("ack") } + assertTrue( + "replay must precede the acknowledgement", + native.calls.indexOf("pending") in 0 until firstAck, + ) + } finally { + client.close() + } } - } @Test - fun failedCommitStaysQueuedAndIsReplayedWithoutDuplicateAcknowledgement() = runTest { - val native = DurableNative() - native.drainResults += UpdateDrainDto(listOf(durableBatch(1)), emptyList(), false) - val client = client(native, StandardTestDispatcher(testScheduler)) - var attempts = 0 - client.installDurableUpdateSink { _ -> - attempts++ - attempts > 1 - } - try { - assertTrue(client.connect() is Outcome.Ok) - settle(400) - assertEquals(2, attempts) - assertEquals(1, native.calls.count { it.startsWith("ack") }) - val firstAck = native.calls.indexOfFirst { it.startsWith("ack") } - assertTrue( - "replay must precede the acknowledgement", - native.calls.indexOf("pending") in 0 until firstAck - ) - } finally { - client.close() + fun batchesAreNotAcknowledgedUntilASinkExistsAndThenAreReplayed() = + runTest { + val native = DurableNative() + native.drainResults += UpdateDrainDto(listOf(durableBatch(1)), emptyList(), false) + val client = client(native, StandardTestDispatcher(testScheduler)) + val committed = mutableListOf() + try { + assertTrue(client.connect() is Outcome.Ok) + settle() + assertTrue(native.calls.none { it.startsWith("ack") }) + client.installDurableUpdateSink { batch -> + committed += batch.batchId + true + } + settle() + assertEquals(listOf(1L), committed) + assertEquals(listOf("ack:7:1"), native.calls.filter { it.startsWith("ack") }) + } finally { + client.close() + } } - } @Test - fun batchesAreNotAcknowledgedUntilASinkExistsAndThenAreReplayed() = runTest { - val native = DurableNative() - native.drainResults += UpdateDrainDto(listOf(durableBatch(1)), emptyList(), false) - val client = client(native, StandardTestDispatcher(testScheduler)) - val committed = mutableListOf() - try { - assertTrue(client.connect() is Outcome.Ok) - settle() - assertTrue(native.calls.none { it.startsWith("ack") }) - client.installDurableUpdateSink { batch -> - committed += batch.batchId + fun duplicateReceiptIsAcknowledgedAndNotReplayed() = + runTest { + val native = DurableNative() + native.unacked += durableBatch(1) + val client = client(native, StandardTestDispatcher(testScheduler)) + var commits = 0 + client.installDurableUpdateSink { + commits++ true } - settle() - assertEquals(listOf(1L), committed) - assertEquals(listOf("ack:7:1"), native.calls.filter { it.startsWith("ack") }) - } finally { - client.close() - } - } - - @Test - fun duplicateReceiptIsAcknowledgedAndNotReplayed() = runTest { - val native = DurableNative() - native.unacked += durableBatch(1) - val client = client(native, StandardTestDispatcher(testScheduler)) - var commits = 0 - client.installDurableUpdateSink { - commits++ - true - } - try { - assertTrue(client.connect() is Outcome.Ok) - settle() - assertEquals(1, commits) - assertEquals(listOf("ack:7:1"), native.calls.filter { it.startsWith("ack") }) - assertTrue(native.unacked.isEmpty()) - settle(400) - assertEquals(1, commits) - assertTrue(native.calls.count { it.startsWith("ack") } == 1) - } finally { - client.close() + try { + assertTrue(client.connect() is Outcome.Ok) + settle() + assertEquals(1, commits) + assertEquals(listOf("ack:7:1"), native.calls.filter { it.startsWith("ack") }) + assertTrue(native.unacked.isEmpty()) + settle(400) + assertEquals(1, commits) + assertTrue(native.calls.count { it.startsWith("ack") } == 1) + } finally { + client.close() + } } - } @Test - fun savedBatchesAreReplayedBeforeNewUpdatesAreDrained() = runTest { - val native = DurableNative() - native.unacked += listOf(durableBatch(1), durableBatch(2)) - val client = client(native, StandardTestDispatcher(testScheduler)) - client.installDurableUpdateSink { batch -> - native.calls += "commit:${batch.batchId}" - true - } - try { - assertTrue(client.connect() is Outcome.Ok) - settle() - val firstDrain = native.calls.indexOf("drain") - val lastCommit = native.calls.indexOf("commit:2") - assertTrue("calls=${native.calls}", lastCommit in 0 until firstDrain) - } finally { - client.close() + fun savedBatchesAreReplayedBeforeNewUpdatesAreDrained() = + runTest { + val native = DurableNative() + native.unacked += listOf(durableBatch(1), durableBatch(2)) + val client = client(native, StandardTestDispatcher(testScheduler)) + client.installDurableUpdateSink { batch -> + native.calls += "commit:${batch.batchId}" + true + } + try { + assertTrue(client.connect() is Outcome.Ok) + settle() + val firstDrain = native.calls.indexOf("drain") + val lastCommit = native.calls.indexOf("commit:2") + assertTrue("calls=${native.calls}", lastCommit in 0 until firstDrain) + } finally { + client.close() + } } - } @Test - fun uploadedOptionsPreserveCallerIdentityFlagsAndAuthoritativeResultModes() = runTest { - val native = object : RecordingNative() { - override fun sendUploadedMediaWithOptions( - handle: Long, - chatId: Long, - item: uniffi.monogram_mtproto.UploadItemDto, - replyToMsgId: Int, - topMsgId: Int, - entitiesJson: String?, - options: uniffi.monogram_mtproto.SendOptions - ): uniffi.monogram_mtproto.OutgoingMessageResult { - assertEquals(7L, chatId); assertEquals(123L, item.randomId) - assertEquals(8, replyToMsgId); assertEquals(9, topMsgId) - assertTrue(options.silent); assertEquals(0x7ffffffe, options.scheduleDate) - return uniffi.monogram_mtproto.OutgoingMessageResult.Scheduled( - uniffi.monogram_mtproto.ScheduledMessageDto(sampleMessage(7, 37), false) - ) - } - - override fun sendUploadedAlbumWithOptions( - handle: Long, chatId: Long, items: List, - replyToMsgId: Int, topMsgId: Int, options: uniffi.monogram_mtproto.SendOptions - ): List { - assertEquals(listOf(123L, 456L), items.map { it.randomId }) - assertTrue(options.silent) - return listOf( - uniffi.monogram_mtproto.OutgoingMessageResult.Sent(sampleMessage(7, 38)), - uniffi.monogram_mtproto.OutgoingMessageResult.Scheduled( - uniffi.monogram_mtproto.ScheduledMessageDto( - sampleMessage(7, 39), - false + fun uploadedOptionsPreserveCallerIdentityFlagsAndAuthoritativeResultModes() = + runTest { + val native = + object : RecordingNative() { + override fun sendUploadedMediaWithOptions( + handle: Long, + chatId: Long, + item: uniffi.monogram_mtproto.UploadItemDto, + replyToMsgId: Int, + topMsgId: Int, + entitiesJson: String?, + options: uniffi.monogram_mtproto.SendOptions, + ): uniffi.monogram_mtproto.OutgoingMessageResult { + assertEquals(7L, chatId) + assertEquals(123L, item.randomId) + assertEquals(8, replyToMsgId) + assertEquals(9, topMsgId) + assertTrue(options.silent) + assertEquals(0x7ffffffe, options.scheduleDate) + return uniffi.monogram_mtproto.OutgoingMessageResult.Scheduled( + uniffi.monogram_mtproto.ScheduledMessageDto( + sampleMessage(7, 37), + false + ), ) - ) + } + + override fun sendUploadedAlbumWithOptions( + handle: Long, + chatId: Long, + items: List, + replyToMsgId: Int, + topMsgId: Int, + options: uniffi.monogram_mtproto.SendOptions, + ): List { + assertEquals(listOf(123L, 456L), items.map { it.randomId }) + assertTrue(options.silent) + return listOf( + uniffi.monogram_mtproto.OutgoingMessageResult.Sent( + sampleMessage( + 7, + 38 + ) + ), + uniffi.monogram_mtproto.OutgoingMessageResult.Scheduled( + uniffi.monogram_mtproto.ScheduledMessageDto( + sampleMessage(7, 39), + false, + ), + ), + ) + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + val item = + org.monogram.core.models.UploadItem( + path = "synthetic-unused", + kind = "photo", + randomId = 123, ) + val options = + org.monogram.core.models + .MessageSendOptions(silent = true, scheduleDate = 0x7ffffffe) + try { + val single = + client.sendUploadedMediaWithOptions( + PeerId(7), + item, + options, + 8, + 9 + ) as Outcome.Ok + assertTrue(single.value is org.monogram.core.models.OutgoingMessageResult.Scheduled) + val album = + client.sendUploadedAlbumWithOptions( + PeerId(7), + listOf(item, item.copy(randomId = 456)), + options, + ) as Outcome.Ok + assertTrue(album.value[0] is org.monogram.core.models.OutgoingMessageResult.Sent) + assertTrue(album.value[1] is org.monogram.core.models.OutgoingMessageResult.Scheduled) + } finally { + client.close() } } - val client = client(native, StandardTestDispatcher(testScheduler)) - val item = org.monogram.core.models.UploadItem( - path = "synthetic-unused", - kind = "photo", - randomId = 123 - ) - val options = - org.monogram.core.models.MessageSendOptions(silent = true, scheduleDate = 0x7ffffffe) - try { - val single = - client.sendUploadedMediaWithOptions(PeerId(7), item, options, 8, 9) as Outcome.Ok - assertTrue(single.value is org.monogram.core.models.OutgoingMessageResult.Scheduled) - val album = client.sendUploadedAlbumWithOptions( - PeerId(7), - listOf(item, item.copy(randomId = 456)), - options - ) as Outcome.Ok - assertTrue(album.value[0] is org.monogram.core.models.OutgoingMessageResult.Sent) - assertTrue(album.value[1] is org.monogram.core.models.OutgoingMessageResult.Scheduled) - } finally { - client.close() - } - } @Test - fun failedUploadedOptionsMutationIsNotAutomaticallyReplayed() = runTest { - var attempts = 0 - val native = object : RecordingNative() { - override fun sendUploadedAlbumWithOptions( - handle: Long, chatId: Long, items: List, - replyToMsgId: Int, topMsgId: Int, options: uniffi.monogram_mtproto.SendOptions - ): List { - attempts++ - throw MtprotoException.Message("RPC 500: INTERNAL") - } - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - val result = client.sendUploadedAlbumWithOptions( - PeerId(7), listOf( - org.monogram.core.models.UploadItem( - path = "synthetic-unused", - kind = "photo", - randomId = 123 + fun failedUploadedOptionsMutationIsNotAutomaticallyReplayed() = + runTest { + var attempts = 0 + val native = + object : RecordingNative() { + override fun sendUploadedAlbumWithOptions( + handle: Long, + chatId: Long, + items: List, + replyToMsgId: Int, + topMsgId: Int, + options: uniffi.monogram_mtproto.SendOptions, + ): List { + attempts++ + throw MtprotoException.Message("RPC 500: INTERNAL") + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + val result = + client.sendUploadedAlbumWithOptions( + PeerId(7), + listOf( + org.monogram.core.models.UploadItem( + path = "synthetic-unused", + kind = "photo", + randomId = 123, + ), + ), + org.monogram.core.models + .MessageSendOptions(silent = true), ) - ), - org.monogram.core.models.MessageSendOptions(silent = true) - ) - assertTrue(result is Outcome.Err) - assertEquals(1, attempts) - } finally { - client.close() + assertTrue(result is Outcome.Err) + assertEquals(1, attempts) + } finally { + client.close() + } } - } @Test - fun viewerPageApisPreserveOpaqueCursorsCountsAndTerminalOffsets() = runTest { - val offsets = mutableListOf() - val native = object : RecordingNative() { - override fun getMessageReactionsPage( - handle: Long, - chatId: Long, - messageId: Int, - offset: String? - ): uniffi.monogram_mtproto.ReactionPeersDto { - offsets += offset - return uniffi.monogram_mtproto.ReactionPeersDto( - peers = emptyList(), - count = 60, - nextOffset = if (offset == null) "opaque+/=cursor" else "" - ) - } - - override fun getPollVotesPage( - handle: Long, - chatId: Long, - messageId: Int, - offset: String? - ): uniffi.monogram_mtproto.PollVotersDto { - offsets += offset - return uniffi.monogram_mtproto.PollVotersDto( - voters = emptyList(), - count = 75, - nextOffset = "poll-next" - ) + fun viewerPageApisPreserveOpaqueCursorsCountsAndTerminalOffsets() = + runTest { + val offsets = mutableListOf() + val native = + object : RecordingNative() { + override fun getMessageReactionsPage( + handle: Long, + chatId: Long, + messageId: Int, + offset: String?, + ): uniffi.monogram_mtproto.ReactionPeersDto { + offsets += offset + return uniffi.monogram_mtproto.ReactionPeersDto( + peers = emptyList(), + count = 60, + nextOffset = if (offset == null) "opaque+/=cursor" else "", + ) + } + + override fun getPollVotesPage( + handle: Long, + chatId: Long, + messageId: Int, + offset: String?, + ): uniffi.monogram_mtproto.PollVotersDto { + offsets += offset + return uniffi.monogram_mtproto.PollVotersDto( + voters = emptyList(), + count = 75, + nextOffset = "poll-next", + ) + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.getReactionUsersPage(PeerId(7), 0, null) is Outcome.Err) + assertTrue(offsets.isEmpty()) + val first = (client.getReactionUsersPage(PeerId(7), 37, null) as Outcome.Ok).value + assertEquals(60, first.totalCount) + assertEquals("opaque+/=cursor", first.nextOffset) + val second = + (client.getReactionUsersPage( + PeerId(7), + 37, + first.nextOffset + ) as Outcome.Ok).value + assertEquals(null, second.nextOffset) + val poll = + (client.getPollVotersPage(PeerId(7), 37, "poll-offset") as Outcome.Ok).value + assertEquals(75, poll.totalCount) + assertEquals("poll-next", poll.nextOffset) + assertEquals(listOf(null, "opaque+/=cursor", "poll-offset"), offsets) + } finally { + client.close() } } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.getReactionUsersPage(PeerId(7), 0, null) is Outcome.Err) - assertTrue(offsets.isEmpty()) - val first = (client.getReactionUsersPage(PeerId(7), 37, null) as Outcome.Ok).value - assertEquals(60, first.totalCount); assertEquals("opaque+/=cursor", first.nextOffset) - val second = - (client.getReactionUsersPage(PeerId(7), 37, first.nextOffset) as Outcome.Ok).value - assertEquals(null, second.nextOffset) - val poll = (client.getPollVotersPage(PeerId(7), 37, "poll-offset") as Outcome.Ok).value - assertEquals(75, poll.totalCount); assertEquals("poll-next", poll.nextOffset) - assertEquals(listOf(null, "opaque+/=cursor", "poll-offset"), offsets) - } finally { - client.close() - } - } @Test - fun closePollPreservesIdentityAndDoesNotReplayUncertainMutation() = runTest { - var attempts = 0 - val native = object : RecordingNative() { - override fun closePoll(handle: Long, chatId: Long, messageId: Int) { - attempts++ - assertEquals(7L, chatId) - assertEquals(37, messageId) - throw MtprotoException.Message("RPC 500: INTERNAL") - } - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.closePoll(PeerId(7), 0) is Outcome.Err) - assertEquals(0, attempts) - assertTrue(client.closePoll(PeerId(7), 37) is Outcome.Err) - assertEquals(1, attempts) - } finally { - client.close() + fun closePollPreservesIdentityAndDoesNotReplayUncertainMutation() = + runTest { + var attempts = 0 + val native = + object : RecordingNative() { + override fun closePoll( + handle: Long, + chatId: Long, + messageId: Int, + ) { + attempts++ + assertEquals(7L, chatId) + assertEquals(37, messageId) + throw MtprotoException.Message("RPC 500: INTERNAL") + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.closePoll(PeerId(7), 0) is Outcome.Err) + assertEquals(0, attempts) + assertTrue(client.closePoll(PeerId(7), 37) is Outcome.Err) + assertEquals(1, attempts) + } finally { + client.close() + } } - } @Test - fun exactMessageLookupPreservesIdentityAndDeletionAndRejectsForeignResponses() = runTest { - var response: MessageDto? = sampleMessage(7, 37) - var reads = 0 - val native = object : RecordingNative() { - override fun getMessage(handle: Long, chatId: Long, messageId: Int): MessageDto? { - reads++ - assertEquals(7L, chatId) - assertEquals(37, messageId) - return response - } - } - val client = client(native, StandardTestDispatcher(testScheduler)) - try { - assertTrue(client.getMessage(PeerId(7), 0) is Outcome.Err) - assertEquals(0, reads) - val found = client.getMessage(PeerId(7), 37) as Outcome.Ok - assertEquals(37, found.value?.id?.id) - assertEquals(PeerId(7), found.value?.id?.chatId) - response = null - assertEquals(null, (client.getMessage(PeerId(7), 37) as Outcome.Ok).value) - response = sampleMessage(8, 37) - assertTrue(client.getMessage(PeerId(7), 37) is Outcome.Err) - response = sampleMessage(7, 38) - assertTrue(client.getMessage(PeerId(7), 37) is Outcome.Err) - } finally { - client.close() + fun exactMessageLookupPreservesIdentityAndDeletionAndRejectsForeignResponses() = + runTest { + var response: MessageDto? = sampleMessage(7, 37) + var reads = 0 + val native = + object : RecordingNative() { + override fun getMessage( + handle: Long, + chatId: Long, + messageId: Int, + ): MessageDto? { + reads++ + assertEquals(7L, chatId) + assertEquals(37, messageId) + return response + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.getMessage(PeerId(7), 0) is Outcome.Err) + assertEquals(0, reads) + val found = client.getMessage(PeerId(7), 37) as Outcome.Ok + assertEquals(37, found.value?.id?.id) + assertEquals(PeerId(7), found.value?.id?.chatId) + response = null + assertEquals(null, (client.getMessage(PeerId(7), 37) as Outcome.Ok).value) + response = sampleMessage(8, 37) + assertTrue(client.getMessage(PeerId(7), 37) is Outcome.Err) + response = sampleMessage(7, 38) + assertTrue(client.getMessage(PeerId(7), 37) is Outcome.Err) + } finally { + client.close() + } } - } private fun client( native: MtprotoNative, @@ -1656,14 +2275,17 @@ class BridgedMtprotoClientLifecycleTest { assertEquals(restricted, restored.mediaPreviewRestricted) assertEquals( restricted != false, - restored.toModel().mediaPreviewRestricted + restored.toModel().mediaPreviewRestricted, ) assertEquals(0, buffer.remaining()) } org.junit.Assert.assertNull(sampleMessage(7L, 3).mediaPreviewRestricted) } - private fun sampleMessage(chatId: Long, id: Int) = MessageDto( + private fun sampleMessage( + chatId: Long, + id: Int, + ) = MessageDto( chatId = chatId, id = id, senderId = 1L, @@ -1702,15 +2324,34 @@ class BridgedMtprotoClientLifecycleTest { ) private open class RecordingNative : MtprotoNative by MtprotoNative.Stub { + override fun startUpdates(handle: Long) = Unit + + override fun updateLazySyncConfig( + handle: Long, + lazy: Boolean, + exceptions: List, + ) = Unit + override fun drainUpdateBatches(handle: Long) = UpdateDrainDto(emptyList(), drainUpdates(handle), false) var created = 0 val destroyed = mutableListOf() - override fun createClient(apiId: Int, apiHash: String, sessionPath: String): Long = (++created).toLong() + + override fun createClient( + apiId: Int, + apiHash: String, + sessionPath: String, + ): Long = (++created).toLong() + override fun connect(handle: Long) = Unit + override fun isAuthorized(handle: Long) = false + override fun logout(handle: Long) = Unit - override fun destroyClient(handle: Long) { destroyed += handle } + + override fun destroyClient(handle: Long) { + destroyed += handle + } } } diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/session/NativeWorkerRegistryTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/session/NativeWorkerRegistryTest.kt new file mode 100644 index 000000000..97d1fa85a --- /dev/null +++ b/network/bridge/src/test/java/org/monogram/network/bridge/session/NativeWorkerRegistryTest.kt @@ -0,0 +1,175 @@ +package org.monogram.network.bridge.session + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class NativeWorkerRegistryTest { + @Test + fun callerCancellationDoesNotCompleteTheWorker() = runBlocking { + val registry = NativeWorkerRegistry() + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val cancels = AtomicInteger() + val cleanups = AtomicInteger() + val worker = + registry.tryRegister({ cancels.incrementAndGet() }, { cleanups.incrementAndGet() })!! + val executor = Executors.newSingleThreadExecutor() + try { + val native = executor.submit { + entered.countDown() + try { + check(release.await(5, TimeUnit.SECONDS)) + } finally { + worker.complete() + worker.complete() + } + } + assertTrue(entered.await(5, TimeUnit.SECONDS)) + val caller = Job() + caller.invokeOnCompletion { worker.cancel() } + caller.cancel() + registry.close() + registry.close() + val waiting = launch(start = CoroutineStart.UNDISPATCHED) { registry.awaitClosed() } + assertFalse(waiting.isCompleted) + assertEquals(1, cancels.get()) + assertEquals(0, cleanups.get()) + release.countDown() + native.get(5, TimeUnit.SECONDS) + withTimeout(5_000) { waiting.join(); registry.awaitClosed() } + assertEquals(1, cleanups.get()) + assertNull(registry.tryRegister({}, {})) + } finally { + release.countDown() + executor.shutdownNow() + } + } + + @Test + fun closeRacesAdmissionWithoutLosingAdmittedWorkers() = runBlocking { + val executor = Executors.newFixedThreadPool(2) + try { + repeat(200) { + val registry = NativeWorkerRegistry() + val start = CountDownLatch(1) + val cancelled = AtomicInteger() + val cleaned = AtomicInteger() + val admission = executor.submit { + check(start.await(5, TimeUnit.SECONDS)) + registry.tryRegister( + { cancelled.incrementAndGet() }, + { cleaned.incrementAndGet() }) + } + val close = executor.submit { + check(start.await(5, TimeUnit.SECONDS)) + registry.close() + } + start.countDown() + val worker = admission.get(5, TimeUnit.SECONDS) + close.get(5, TimeUnit.SECONDS) + if (worker != null) { + assertEquals(1, cancelled.get()) + val waiting = + launch(start = CoroutineStart.UNDISPATCHED) { registry.awaitClosed() } + assertFalse(waiting.isCompleted) + worker.complete() + withTimeout(5_000) { waiting.join() } + assertEquals(1, cleaned.get()) + } else { + assertEquals(0, cancelled.get()) + withTimeout(5_000) { registry.awaitClosed() } + } + assertNull(registry.tryRegister({}, {})) + } + } finally { + executor.shutdownNow() + } + } + + @Test + fun cancellationRunsOutsideLockAndCleanupWaitsForIt() = runBlocking { + val registry = NativeWorkerRegistry() + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val cleanups = AtomicInteger() + val executor = Executors.newFixedThreadPool(2) + val worker = registry.tryRegister({ + entered.countDown() + check(release.await(5, TimeUnit.SECONDS)) + }, { cleanups.incrementAndGet() })!! + try { + val close = executor.submit { registry.close() } + assertTrue(entered.await(5, TimeUnit.SECONDS)) + val finished = executor.submit { worker.cancel(); worker.complete(); worker.complete() } + finished.get(5, TimeUnit.SECONDS) + assertEquals(0, cleanups.get()) + val waiting = launch(start = CoroutineStart.UNDISPATCHED) { registry.awaitClosed() } + assertFalse(waiting.isCompleted) + release.countDown() + close.get(5, TimeUnit.SECONDS) + withTimeout(5_000) { waiting.join(); registry.awaitClosed() } + assertEquals(1, cleanups.get()) + } finally { + release.countDown() + executor.shutdownNow() + } + } + + @Test + fun failedCleanupIsReportedByEveryWaiter() = runBlocking { + val registry = NativeWorkerRegistry() + val failure = IllegalStateException("cleanup failed") + val worker = registry.tryRegister({}, { throw failure })!! + registry.close() + try { + worker.complete() + error("Expected cleanup failure") + } catch (error: IllegalStateException) { + assertTrue(error === failure) + } + worker.complete() + repeat(2) { + try { + registry.awaitClosed() + error("Expected drain failure") + } catch (error: IllegalStateException) { + assertTrue(error === failure || error.cause === failure) + } + } + } + + @Test + fun cancellingAWaiterDoesNotCancelSharedDrain() = runBlocking { + val registry = NativeWorkerRegistry() + val worker = registry.tryRegister({}, {}) + assertNotNull(worker) + registry.close() + val started = CompletableDeferred() + val first = launch(start = CoroutineStart.UNDISPATCHED) { + started.complete(Unit) + registry.awaitClosed() + } + started.await() + first.cancel() + first.join() + val second = launch(start = CoroutineStart.UNDISPATCHED) { registry.awaitClosed() } + assertFalse(second.isCompleted) + worker!!.complete() + withTimeout(5_000) { second.join(); registry.awaitClosed() } + assertTrue(second.isCompleted) + } +} diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/session/SessionCoreOwnershipTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/session/SessionCoreOwnershipTest.kt index 8226b9ff0..119ba86d3 100644 --- a/network/bridge/src/test/java/org/monogram/network/bridge/session/SessionCoreOwnershipTest.kt +++ b/network/bridge/src/test/java/org/monogram/network/bridge/session/SessionCoreOwnershipTest.kt @@ -11,11 +11,59 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.monogram.core.common.Outcome import org.monogram.core.common.TelegramCredentials import org.monogram.mtproto.MtprotoNative @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class SessionCoreOwnershipTest { + @Test + fun bindingRejectionIsPacedPerAccountAndRetriesAfterCooldown() = runTest { + var now = 0L + var rejectedCalls = 0 + var otherCalls = 0 + val dispatcher = StandardTestDispatcher(testScheduler) + val failing = object : MtprotoNative by MtprotoNative.Stub { + override fun connect(handle: Long) { + rejectedCalls++ + throw uniffi.monogram_mtproto.MtprotoException.Message( + "temp bind RPC 400: ENCRYPTED_MESSAGE_INVALID", + ) + } + } + val working = object : MtprotoNative by MtprotoNative.Stub { + override fun connect(handle: Long) { + otherCalls++ + } + } + val first = SessionCore( + TelegramCredentials(1, "fixture"), "first.session", failing, + 1_000L, dispatcher, {}, MonotonicClock { now }, + ) + val second = SessionCore( + TelegramCredentials(1, "fixture"), "second.session", working, + 1_000L, dispatcher, {}, MonotonicClock { now }, + ) + try { + val results = (1..8).map { async { first.connect() } }.map { it.await() } + assertTrue(results.all { it is Outcome.Err }) + assertEquals(1, rejectedCalls) + assertFalse(first.sessionDead) + assertTrue(second.connect() is Outcome.Ok) + assertEquals(1, otherCalls) + now = 1_999 + assertTrue(first.connect() is Outcome.Err) + assertEquals(1, rejectedCalls) + now = 2_000 + assertTrue(first.connect() is Outcome.Err) + assertEquals(2, rejectedCalls) + assertFalse(first.sessionDead) + } finally { + first.close() + second.close() + } + } + @Test fun temporaryKeyRecoveryDoesNotRequireAccountReauthorization() { val temporary = org.monogram.core.common.telegram.TelegramError.parse( diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/updates/ChannelDifferenceMappingTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/updates/ChannelDifferenceMappingTest.kt new file mode 100644 index 000000000..c4f0cb572 --- /dev/null +++ b/network/bridge/src/test/java/org/monogram/network/bridge/updates/ChannelDifferenceMappingTest.kt @@ -0,0 +1,16 @@ +package org.monogram.network.bridge.updates + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.monogram.network.bridge.MtprotoUpdate +import uniffi.monogram_mtproto.UpdateEventDto + +class ChannelDifferenceMappingTest { + @Test + fun channelDifferenceInvalidationMarkerSurvivesMapping() { + assertEquals( + listOf(MtprotoUpdate.Ignored("channel_difference_too_long:-10042")), + UpdateEventDto.Ignored("channel_difference_too_long:-10042").toMtprotoUpdates(), + ) + } +} diff --git a/network/http/src/main/java/org/monogram/network/http/DownloadQueue.kt b/network/http/src/main/java/org/monogram/network/http/DownloadQueue.kt index 4bbac05b1..5295d5caf 100644 --- a/network/http/src/main/java/org/monogram/network/http/DownloadQueue.kt +++ b/network/http/src/main/java/org/monogram/network/http/DownloadQueue.kt @@ -8,11 +8,16 @@ import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readAvailable import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -70,6 +75,8 @@ class DownloadQueue( private val mutex = Mutex() private val workers = mutableListOf() private val cancelled = java.util.concurrent.ConcurrentHashMap.newKeySet() + private val active = java.util.concurrent.ConcurrentHashMap() + private val publicationLock = Any() @Volatile private var started = false @Volatile private var stopped = false @@ -98,8 +105,13 @@ class DownloadQueue( } fun cancel(id: String) { - cancelled.add(id) - statuses.value += (id to DownloadStatus.Cancelled) + val job = synchronized(publicationLock) { + if (statuses.value[id] is DownloadStatus.Completed) return + cancelled.add(id) + statuses.update { it + (id to DownloadStatus.Cancelled) } + active[id] + } + job?.cancel() } fun shutdown() { @@ -119,7 +131,18 @@ class DownloadQueue( return null } - private suspend fun runDownload(request: DownloadRequest) { + private suspend fun runDownload(request: DownloadRequest) = coroutineScope { + val job = launch(start = CoroutineStart.LAZY) { performDownload(request) } + active[request.id] = job + try { + if (cancelled.contains(request.id)) job.cancel() else job.start() + job.join() + } finally { + active.remove(request.id, job) + } + } + + private suspend fun performDownload(request: DownloadRequest) { if (cancelled.contains(request.id)) { setStatus(request.id, DownloadStatus.Cancelled) return @@ -146,8 +169,16 @@ class DownloadQueue( setStatus(request.id, DownloadStatus.Running(total)) } } - if (!partial.renameTo(request.destination)) { - error("cannot publish downloaded file") + val context = currentCoroutineContext() + synchronized(publicationLock) { + context.ensureActive() + if (cancelled.contains(request.id) || stopped) { + throw CancellationException("download cancelled") + } + if (!partial.renameTo(request.destination)) { + error("cannot publish downloaded file") + } + statuses.update { it + (request.id to DownloadStatus.Completed(request.destination)) } } Outcome.Ok(request.destination) } @@ -169,7 +200,9 @@ class DownloadQueue( private suspend fun setStatus(id: String, status: DownloadStatus) { mutex.withLock { - statuses.value += (id to status) + statuses.update { current -> + if (current[id] is DownloadStatus.Cancelled) current else current + (id to status) + } } } } diff --git a/network/http/src/main/java/org/monogram/network/http/FileCache.kt b/network/http/src/main/java/org/monogram/network/http/FileCache.kt index 400ce47b1..6eaa8b9ac 100644 --- a/network/http/src/main/java/org/monogram/network/http/FileCache.kt +++ b/network/http/src/main/java/org/monogram/network/http/FileCache.kt @@ -37,14 +37,19 @@ class FileCache( ) { private var maxBytes: Long = maxBytes private var enforceSize: Boolean = enforceSize + enum class AdmissionFailure { Capacity, Storage, InUse } - class AdmissionException(val reason: AdmissionFailure) : IOException( + + class AdmissionException( + val reason: AdmissionFailure, + ) : IOException( when (reason) { AdmissionFailure.Capacity -> "media exceeds cache capacity" AdmissionFailure.Storage -> "insufficient media cache storage" AdmissionFailure.InUse -> "cache entry is in use" - } + }, ) + private val indexFile = File(root, INDEX_NAME) private val records = LinkedHashMap() private val recordsLock = Any() @@ -61,40 +66,47 @@ class FileCache( private var firstDirtyAt = 0L private var indexFailures = 0 - class Lease internal constructor(val file: File, private val release: () -> Unit) : - AutoCloseable { - private val closed = java.util.concurrent.atomic.AtomicBoolean(false) + class Lease internal constructor( + val file: File, + private val release: () -> Unit, + ) : AutoCloseable { + private val closed = + java.util.concurrent.atomic + .AtomicBoolean(false) + override fun close() { if (closed.compareAndSet(false, true)) release() } } - fun acquire(key: String): Lease? = synchronized(recordsLock) { - val file = get(key) ?: return@synchronized null - val name = diskName(key) - leases[name] = (leases[name] ?: 0) + 1 - Lease(file) { - synchronized(recordsLock) { - val remaining = (leases[name] ?: 1) - 1 - if (remaining == 0) leases.remove(name) else leases[name] = remaining - flushPendingDeletes() + fun acquire(key: String): Lease? = + synchronized(recordsLock) { + val file = get(key) ?: return@synchronized null + val name = diskName(key) + leases[name] = (leases[name] ?: 0) + 1 + Lease(file) { + synchronized(recordsLock) { + val remaining = (leases[name] ?: 1) - 1 + if (remaining == 0) leases.remove(name) else leases[name] = remaining + flushPendingDeletes() + } } } - } /** Leases a file by its physical name; null when it is not a cache file or is gone. */ - fun acquireFile(file: File): Lease? = synchronized(recordsLock) { - if (file.parentFile?.canonicalFile != root.canonicalFile || !file.isFile) return@synchronized null - val name = file.name - leases[name] = (leases[name] ?: 0) + 1 - Lease(file) { - synchronized(recordsLock) { - val remaining = (leases[name] ?: 1) - 1 - if (remaining == 0) leases.remove(name) else leases[name] = remaining - flushPendingDeletes() + fun acquireFile(file: File): Lease? = + synchronized(recordsLock) { + if (file.parentFile?.canonicalFile != root.canonicalFile || !file.isFile) return@synchronized null + val name = file.name + leases[name] = (leases[name] ?: 0) + 1 + Lease(file) { + synchronized(recordsLock) { + val remaining = (leases[name] ?: 1) - 1 + if (remaining == 0) leases.remove(name) else leases[name] = remaining + flushPendingDeletes() + } } } - } private fun protected(key: String): Boolean = (leases[diskName(key)] ?: 0) > 0 || records[key]?.pinned == true || @@ -111,46 +123,59 @@ class FileCache( private fun inFlightBytesExcept(key: String): Long { val name = diskName(key) val partial = partialBytes.filterKeys { it != name }.values.sum() - val reserved = reservations.entries.filter { it.key != name } - .sumOf { (n, bytes) -> (bytes - (partialBytes[n] ?: 0L)).coerceAtLeast(0L) } + val reserved = + reservations.entries + .filter { it.key != name } + .sumOf { (n, bytes) -> (bytes - (partialBytes[n] ?: 0L)).coerceAtLeast(0L) } return partial + reserved } /** Bytes reserved or already in a partial transfer. No cache walk and no hashing. */ - fun inflightBytes(): Long = synchronized(recordsLock) { - val partial = partialBytes.values.sum() - val reserved = reservations.entries.sumOf { (name, bytes) -> - (bytes - (partialBytes[name] ?: 0L)).coerceAtLeast(0L) + fun inflightBytes(): Long = + synchronized(recordsLock) { + val partial = partialBytes.values.sum() + val reserved = + reservations.entries.sumOf { (name, bytes) -> + (bytes - (partialBytes[name] ?: 0L)).coerceAtLeast(0L) + } + partial + reserved } - partial + reserved - } /** Derived from in-memory records and transfer state; no filesystem access. */ - fun accounting(): StorageAccounting = synchronized(recordsLock) { - ensureIndexLoaded() - val protectedSet = protectedNames() - val seen = HashSet() - var completed = 0L - var protectedTotal = 0L - for (record in records.values) { - val name = diskName(record.key) - if (!seen.add(name)) continue - if (name in protectedSet) protectedTotal += record.bytes else completed += record.bytes - } - for (name in protectedSet) { - if (name !in seen) protectedTotal += File(root, name).length() - } - StorageAccounting( - completedBytes = completed, - protectedBytes = protectedTotal, - partialBytes = partialBytes.values.sum(), - reservedBytes = reservations.entries - .sumOf { (n, bytes) -> (bytes - (partialBytes[n] ?: 0L)).coerceAtLeast(0L) }, - ) - } + fun accounting(): StorageAccounting = + synchronized(recordsLock) { + ensureIndexLoaded() + val protectedSet = protectedNames() + val seen = HashSet() + var completed = 0L + var protectedTotal = 0L + for (record in records.values) { + val name = diskName(record.key) + if (!seen.add(name)) continue + if (name in protectedSet) protectedTotal += record.bytes else completed += record.bytes + } + for (name in protectedSet) { + if (name !in seen) protectedTotal += File(root, name).length() + } + StorageAccounting( + completedBytes = completed, + protectedBytes = protectedTotal, + partialBytes = partialBytes.values.sum(), + reservedBytes = + reservations.entries + .sumOf { (n, bytes) -> + (bytes - (partialBytes[n] ?: 0L)).coerceAtLeast(0L) + }, + ) + } + + class Reservation internal constructor( + private val release: () -> Unit, + ) : AutoCloseable { + private val closed = + java.util.concurrent.atomic + .AtomicBoolean(false) - class Reservation internal constructor(private val release: () -> Unit) : AutoCloseable { - private val closed = java.util.concurrent.atomic.AtomicBoolean(false) override fun close() { if (closed.compareAndSet(false, true)) release() } @@ -160,7 +185,11 @@ class FileCache( * Holds [bytes] for a transfer about to write. Optional reservations are * refused when protected, partial and reserved space would leave no room. */ - fun reserve(key: String, bytes: Long, optional: Boolean = true): Reservation = + fun reserve( + key: String, + bytes: Long, + optional: Boolean = true, + ): Reservation = synchronized(recordsLock) { ensureIndexLoaded() val name = diskName(key) @@ -180,7 +209,10 @@ class FileCache( } /** Records the current size of this key's `.part` staging file; zero clears it. */ - fun notePartial(key: String, bytes: Long) = synchronized(recordsLock) { + fun notePartial( + key: String, + bytes: Long, + ) = synchronized(recordsLock) { val name = diskName(key) if (bytes <= 0L) partialBytes.remove(name) else partialBytes[name] = bytes } @@ -205,11 +237,12 @@ class FileCache( } /** Rebuilds partial counters and the index from disk; returns tracked bytes minus real bytes. */ - fun reconcile(): Long = synchronized(recordsLock) { - ensureIndexLoaded() - reconcileLocked() - accounting().let { it.completedBytes + it.protectedBytes } - completeBytesOnDisk() - } + fun reconcile(): Long = + synchronized(recordsLock) { + ensureIndexLoaded() + reconcileLocked() + accounting().let { it.completedBytes + it.protectedBytes } - completeBytesOnDisk() + } private fun reconcileLocked() { dropMissing() @@ -226,31 +259,36 @@ class FileCache( } private fun completeBytesOnDisk(): Long = - root.listFiles()?.filter { it.isFile && it.name != INDEX_NAME && !isStaging(it.name) } + root + .listFiles() + ?.filter { it.isFile && it.name != INDEX_NAME && !isStaging(it.name) } ?.sumOf { it.length() } ?: 0L - fun pin(key: String) = synchronized(recordsLock) { - ensureIndexLoaded() - val record = records[key] ?: return@synchronized - if (record.pinned) return@synchronized - records[key] = record.copy(pinned = true) - markIndexDirty(force = true) - } + fun pin(key: String) = + synchronized(recordsLock) { + ensureIndexLoaded() + val record = records[key] ?: return@synchronized + if (record.pinned) return@synchronized + records[key] = record.copy(pinned = true) + markIndexDirty(force = true) + } - fun unpin(key: String) = synchronized(recordsLock) { - ensureIndexLoaded() - val record = records[key] ?: return@synchronized - if (!record.pinned) return@synchronized - records[key] = record.copy(pinned = false) - markIndexDirty(force = true) - flushPendingDeletes() - } + fun unpin(key: String) = + synchronized(recordsLock) { + ensureIndexLoaded() + val record = records[key] ?: return@synchronized + if (!record.pinned) return@synchronized + records[key] = record.copy(pinned = false) + markIndexDirty(force = true) + flushPendingDeletes() + } /** Writes the batched index now. A failure is retried by the next mutation or flush. */ - fun flushIndex() = synchronized(recordsLock) { - ensureIndexLoaded() - if (dirtyIndexMutations > 0) writeIndex() - } + fun flushIndex() = + synchronized(recordsLock) { + ensureIndexLoaded() + if (dirtyIndexMutations > 0) writeIndex() + } private fun markIndexDirty(force: Boolean = false) { if (dirtyIndexMutations == 0) firstDirtyAt = nowMs() @@ -284,11 +322,12 @@ class FileCache( if (maintainOnInit) maintain() } - internal fun maintain() = synchronized(recordsLock) { - ensureIndexLoaded() - reconcileLocked() - expireInactivePartials() - } + internal fun maintain() = + synchronized(recordsLock) { + ensureIndexLoaded() + reconcileLocked() + expireInactivePartials() + } private fun ensureIndexLoaded() { if (indexLoaded) return @@ -298,22 +337,29 @@ class FileCache( fun fileFor(key: String): File = File(root, diskName(key)) - fun get(key: String): File? = synchronized(recordsLock) { - if (pendingDeletion(key)) return@synchronized null - val name = peekName(key) - val file = File(root, name) - if (!file.isFile) return@synchronized null - if (!diskNames.containsKey(key)) diskNames[key] = name - val now = nowMs() - val previous = lastAccess[name] - lastAccess[name] = now - if (previous == null || now - previous >= ACCESS_TOUCH_MS) file.setLastModified(now) - file - } + fun get(key: String): File? = + synchronized(recordsLock) { + if (pendingDeletion(key)) return@synchronized null + val name = peekName(key) + val file = File(root, name) + if (!file.isFile) return@synchronized null + if (!diskNames.containsKey(key)) diskNames[key] = name + val now = nowMs() + val previous = lastAccess[name] + lastAccess[name] = now + if (previous == null || now - previous >= ACCESS_TOUCH_MS) file.setLastModified(now) + file + } - fun setProtectedKeys(owner: String, keys: Collection) = synchronized(recordsLock) { - if (keys.isEmpty()) protectedKeys.remove(owner) - else protectedKeys[owner] = keys.filter(String::isNotBlank).toSet() + fun setProtectedKeys( + owner: String, + keys: Collection, + ) = synchronized(recordsLock) { + if (keys.isEmpty()) { + protectedKeys.remove(owner) + } else { + protectedKeys[owner] = keys.filter(String::isNotBlank).toSet() + } flushPendingDeletes() } @@ -321,7 +367,7 @@ class FileCache( key: String, source: File, chatId: Long? = null, - kind: String? = null + kind: String? = null, ): Lease = synchronized(recordsLock) { val owner = "publication:${java.util.UUID.randomUUID()}" @@ -336,7 +382,12 @@ class FileCache( } } - fun put(key: String, bytes: ByteArray, chatId: Long? = null, kind: String? = null): File = + fun put( + key: String, + bytes: ByteArray, + chatId: Long? = null, + kind: String? = null, + ): File = synchronized(recordsLock) { ensureIndexLoaded() val file = fileFor(key) @@ -358,15 +409,19 @@ class FileCache( file } - fun putFile(key: String, source: File, chatId: Long? = null, kind: String? = null): File = - putFileInternal(key, source, chatId, kind, temporary = false) + fun putFile( + key: String, + source: File, + chatId: Long? = null, + kind: String? = null, + ): File = putFileInternal(key, source, chatId, kind, temporary = false) private fun putFileInternal( key: String, source: File, chatId: Long?, kind: String?, - temporary: Boolean + temporary: Boolean, ): File = synchronized(recordsLock) { ensureIndexLoaded() @@ -397,7 +452,10 @@ class FileCache( target } - private fun exceedsCapacity(key: String, bytes: Long): Boolean { + private fun exceedsCapacity( + key: String, + bytes: Long, + ): Boolean { if (!enforceSize) return false if (bytes > maxBytes) return true return protectedBytesExcept(key) + inFlightBytesExcept(key) > maxBytes - bytes @@ -405,7 +463,10 @@ class FileCache( private fun protectedBytesExcept(key: String): Long { val names = protectedNames() - return names.asSequence().distinct().filter { it != diskName(key) } + return names + .asSequence() + .distinct() + .filter { it != diskName(key) } .sumOf { File(root, it).length() } } @@ -413,7 +474,7 @@ class FileCache( key: String, bytes: Long, temporary: Boolean = false, - additionalBytes: Long = bytes + additionalBytes: Long = bytes, ) { if ((leases[diskName(key)] ?: 0) > 0 || pendingDeletion(key)) { throw AdmissionException(AdmissionFailure.InUse) @@ -426,23 +487,32 @@ class FileCache( } } - private fun replace(source: File, target: File) { + private fun replace( + source: File, + target: File, + ) { if (android.os.Build.VERSION.SDK_INT == 0 || android.os.Build.VERSION.SDK_INT >= 26) { java.nio.file.Files.move( source.toPath(), target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE, - java.nio.file.StandardCopyOption.REPLACE_EXISTING + java.nio.file.StandardCopyOption.REPLACE_EXISTING, ) return } if (!source.renameTo(target)) throw IOException("cache atomic replacement failed") } - fun remember(key: String, chatId: Long?, kind: String, bytes: Long) { + fun remember( + key: String, + chatId: Long?, + kind: String, + bytes: Long, + ) { synchronized(recordsLock) { ensureIndexLoaded() - records.keys.filter { it != key && diskName(it) == diskName(key) } + records.keys + .filter { it != key && diskName(it) == diskName(key) } .forEach(records::remove) val pinned = records[key]?.pinned == true records[key] = normalized(key, chatId, kind, bytes).copy(pinned = pinned) @@ -452,7 +522,11 @@ class FileCache( } } - fun attribute(key: String, chatId: Long?, kind: String) { + fun attribute( + key: String, + chatId: Long?, + kind: String, + ) { synchronized(recordsLock) { ensureIndexLoaded() val existing = records[key] ?: return @@ -462,45 +536,54 @@ class FileCache( } } - fun records(): List = synchronized(recordsLock) { - ensureIndexLoaded() - records.values.toList() - } - - fun usageByKind(): Map = synchronized(recordsLock) { - ensureIndexLoaded() - val grouped = records.values.groupBy { it.kind } - .mapValues { (_, rows) -> rows.sumOf { it.bytes } } - .toMutableMap() - val indexed = grouped.values.sum() - val leftover = (totalBytes() - indexed).coerceAtLeast(0L) - if (leftover > 0L) { - grouped[KIND_OTHER] = (grouped[KIND_OTHER] ?: 0L) + leftover - } - grouped - } + fun records(): List = + synchronized(recordsLock) { + ensureIndexLoaded() + records.values.toList() + } - fun usageByChat(): Map = synchronized(recordsLock) { - ensureIndexLoaded() - records.values.mapNotNull { row -> - val id = row.chatId ?: return@mapNotNull null - id to row.bytes - }.groupBy({ it.first }, { it.second }).mapValues { it.value.sum() } - } + fun usageByKind(): Map = + synchronized(recordsLock) { + ensureIndexLoaded() + val grouped = + records.values + .groupBy { it.kind } + .mapValues { (_, rows) -> rows.sumOf { it.bytes } } + .toMutableMap() + val indexed = grouped.values.sum() + val leftover = (totalBytes() - indexed).coerceAtLeast(0L) + if (leftover > 0L) { + grouped[KIND_OTHER] = (grouped[KIND_OTHER] ?: 0L) + leftover + } + grouped + } - fun clearChat(chatId: Long): Long { - val keys = synchronized(recordsLock) { + fun usageByChat(): Map = + synchronized(recordsLock) { ensureIndexLoaded() - records.filter { it.value.chatId == chatId }.keys.toList() + records.values + .mapNotNull { row -> + val id = row.chatId ?: return@mapNotNull null + id to row.bytes + }.groupBy({ it.first }, { it.second }) + .mapValues { it.value.sum() } } + + fun clearChat(chatId: Long): Long { + val keys = + synchronized(recordsLock) { + ensureIndexLoaded() + records.filter { it.value.chatId == chatId }.keys.toList() + } return deleteKeys(keys) } fun clearKind(kind: String): Long { - val keys = synchronized(recordsLock) { - ensureIndexLoaded() - records.filter { it.value.kind == kind }.keys.toList() - } + val keys = + synchronized(recordsLock) { + ensureIndexLoaded() + records.filter { it.value.kind == kind }.keys.toList() + } return deleteKeys(keys) } @@ -512,79 +595,88 @@ class FileCache( } /** Deletes all cached files. Used by settings "clear media cache". */ - fun clear(): Long = synchronized(recordsLock) { - ensureIndexLoaded() - backfillUnindexed() - deleteKeys(records.keys.toList()) - } + fun clear(): Long = + synchronized(recordsLock) { + ensureIndexLoaded() + backfillUnindexed() + deleteKeys(records.keys.toList()) + } fun totalBytes(): Long = - root.walkTopDown().filter { it.isFile && it.name != INDEX_NAME && !isStaging(it.name) }.sumOf { it.length() } + root.walkTopDown().filter { it.isFile && it.name != INDEX_NAME && !isStaging(it.name) } + .sumOf { it.length() } /** `null` turns size eviction off. A finite value trims oldest unpinned files down to it. */ - fun setMaxBytes(maxBytes: Long?) = synchronized(recordsLock) { - if (maxBytes == null) { - enforceSize = false - } else { - enforceSize = true - this.maxBytes = maxBytes + fun setMaxBytes(maxBytes: Long?) = + synchronized(recordsLock) { + if (maxBytes == null) { + enforceSize = false + } else { + enforceSize = true + this.maxBytes = maxBytes + } + trimIfNeeded() } - trimIfNeeded() - } - fun trimIfNeeded() = synchronized(recordsLock) { - ensureIndexLoaded() - if (!reconciled) reconcileLocked() - if (!enforceSize) return@synchronized - var total = accounting().totalBytes - if (total <= maxBytes) return@synchronized - val protectedSet = protectedNames() - val candidates = records.values - .filter { diskName(it.key) !in protectedSet } - .distinctBy { diskName(it.key) } - .sortedBy { lastAccess[diskName(it.key)] ?: fileFor(it.key).lastModified() } - for (record in candidates) { - if (total <= maxBytes) break - val name = diskName(record.key) - val file = File(root, name) - val length = file.length() - if (!file.exists() || file.delete()) { - total -= length - records.keys.filter { diskName(it) == name }.forEach { key -> - records.remove(key) - diskNames.remove(key) + fun trimIfNeeded() = + synchronized(recordsLock) { + ensureIndexLoaded() + if (!reconciled) reconcileLocked() + if (!enforceSize) return@synchronized + var total = accounting().totalBytes + if (total <= maxBytes) return@synchronized + val protectedSet = protectedNames() + val keysByName = records.keys.groupBy(::diskName) + val candidates = + records.values + .filter { diskName(it.key) !in protectedSet } + .distinctBy { diskName(it.key) } + .sortedBy { lastAccess[diskName(it.key)] ?: fileFor(it.key).lastModified() } + for (record in candidates) { + if (total <= maxBytes) break + val name = diskName(record.key) + val file = File(root, name) + val length = file.length() + if (!file.exists() || file.delete()) { + total -= length + keysByName[name].orEmpty().forEach { key -> + records.remove(key) + diskNames.remove(key) + } + lastAccess.remove(name) } - lastAccess.remove(name) } + markIndexDirty(force = true) } - markIndexDirty(force = true) - } - private fun deleteKeys(keys: List, force: Boolean = false): Long = + private fun deleteKeys( + keys: List, + force: Boolean = false, + ): Long = synchronized(recordsLock) { - ensureIndexLoaded() - var freed = 0L - keys.forEach { key -> - // Pinned downloads are only removed by an explicit remove(). - if (!force && records[key]?.pinned == true) return@forEach - if (protected(key)) { - pendingDeletes.add(key) - return@forEach - } - val file = fileFor(key) - val len = if (file.exists()) file.length() else 0L - val name = diskName(key) - if (!file.exists() || file.delete()) { - freed += len - records.remove(key) - diskNames.remove(key) - pendingDeletes.remove(key) - lastAccess.remove(name) + ensureIndexLoaded() + var freed = 0L + keys.forEach { key -> + // Pinned downloads are only removed by an explicit remove(). + if (!force && records[key]?.pinned == true) return@forEach + if (protected(key)) { + pendingDeletes.add(key) + return@forEach + } + val file = fileFor(key) + val len = if (file.exists()) file.length() else 0L + val name = diskName(key) + if (!file.exists() || file.delete()) { + freed += len + records.remove(key) + diskNames.remove(key) + pendingDeletes.remove(key) + lastAccess.remove(name) + } } - } markIndexDirty(force = true) - freed - } + freed + } private fun dropMissing() { val gone = records.filter { !fileFor(it.key).exists() }.keys @@ -598,19 +690,23 @@ class FileCache( private fun backfillUnindexed() { val known = records.keys.map { fileFor(it).name }.toSet() - val files = root.listFiles()?.filter { - it.isFile && it.name != INDEX_NAME && !isStaging(it.name) - }.orEmpty() + val files = + root + .listFiles() + ?.filter { + it.isFile && it.name != INDEX_NAME && !isStaging(it.name) + }.orEmpty() var added = false files.forEach { file -> if (file.name in known) return@forEach val key = ORPHAN_PREFIX + file.name - records[key] = CacheRecord( - key = key, - chatId = null, - kind = KIND_OTHER, - bytes = file.length(), - ) + records[key] = + CacheRecord( + key = key, + chatId = null, + kind = KIND_OTHER, + bytes = file.length(), + ) added = true } if (added) markIndexDirty(force = true) @@ -633,23 +729,30 @@ class FileCache( if (changed) markIndexDirty(force = true) } - private fun normalized(key: String, chatId: Long?, kind: String, bytes: Long): CacheRecord { + private fun normalized( + key: String, + chatId: Long?, + kind: String, + bytes: Long, + ): CacheRecord { val documentId = attributedDocumentId(key) val detach = documentId != null && chatId == documentId val storedChat = if (detach) null else chatId - val storedKind = when { - key.startsWith("gif:") -> KIND_GIFS - detach && kind != KIND_STICKERS && kind != KIND_PHOTOS -> KIND_GIFS - else -> kind - } + val storedKind = + when { + key.startsWith("gif:") -> KIND_GIFS + detach && kind != KIND_STICKERS && kind != KIND_PHOTOS -> KIND_GIFS + else -> kind + } return CacheRecord(key, storedChat, storedKind, bytes) } private fun persistIndex() { - val text = records.values.joinToString("\n") { row -> - val chat = row.chatId?.toString().orEmpty() - "${row.key}\t$chat\t${row.kind}\t${row.bytes}\t${if (row.pinned) 1 else 0}" - } + val text = + records.values.joinToString("\n") { row -> + val chat = row.chatId?.toString().orEmpty() + "${row.key}\t$chat\t${row.kind}\t${row.bytes}\t${if (row.pinned) 1 else 0}" + } val staging = File.createTempFile("index-", ".part", root) try { FileOutputStream(staging).use { output -> @@ -672,9 +775,10 @@ class FileCache( return if (key.startsWith(ORPHAN_PREFIX)) key.removePrefix(ORPHAN_PREFIX) else hash(key) } - private fun diskName(key: String): String = diskNames.computeIfAbsent(key) { raw -> - if (raw.startsWith(ORPHAN_PREFIX)) raw.removePrefix(ORPHAN_PREFIX) else hash(raw) - } + private fun diskName(key: String): String = + diskNames.computeIfAbsent(key) { raw -> + if (raw.startsWith(ORPHAN_PREFIX)) raw.removePrefix(ORPHAN_PREFIX) else hash(raw) + } private fun hash(key: String): String { val digest = checkNotNull(sha256.get()) @@ -696,9 +800,10 @@ class FileCache( private const val INDEX_BATCH_MS = 2_000L private const val ACCESS_TOUCH_MS = 5_000L private val HEX = "0123456789abcdef".toCharArray() - private val sha256 = object : ThreadLocal() { - override fun initialValue(): MessageDigest = MessageDigest.getInstance("SHA-256") - } + private val sha256 = + object : ThreadLocal() { + override fun initialValue(): MessageDigest = MessageDigest.getInstance("SHA-256") + } private const val STAGING_PUBLISH = "publish-" private const val STAGING_INDEX = "index-" @@ -711,7 +816,10 @@ class FileCache( const val KIND_GIFS = "gifs" const val KIND_OTHER = "other" - fun inferKind(key: String, mediaKind: String? = null): String { + fun inferKind( + key: String, + mediaKind: String? = null, + ): String { val hint = mediaKind.orEmpty() return when { hint == "photo" || hint == "webpage" || key.startsWith("photo:") -> KIND_PHOTOS @@ -726,11 +834,12 @@ class FileCache( } private fun attributedDocumentId(key: String): Long? { - val body = when { - key.startsWith("gif:") -> key.removePrefix("gif:") - key.startsWith("doc:") -> key.removePrefix("doc:") - else -> return null - } + val body = + when { + key.startsWith("gif:") -> key.removePrefix("gif:") + key.startsWith("doc:") -> key.removePrefix("doc:") + else -> return null + } val id = body.substringBefore(':') if (id.isEmpty() || id.any { !it.isDigit() }) return null return id.toLongOrNull() diff --git a/network/http/src/main/java/org/monogram/network/http/MapTiles.kt b/network/http/src/main/java/org/monogram/network/http/MapTiles.kt index 2a1ff233f..47ab1ee4e 100644 --- a/network/http/src/main/java/org/monogram/network/http/MapTiles.kt +++ b/network/http/src/main/java/org/monogram/network/http/MapTiles.kt @@ -6,8 +6,13 @@ import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpHeaders import io.ktor.http.isSuccess import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import java.io.File @@ -33,34 +38,56 @@ class MapTileStore( if (file.isFile && System.currentTimeMillis() - file.lastModified() < TTL_MS) { return@withContext file } - gate.withPermit { - runCatching { - file.parentFile?.mkdirs() - val partial = File(file.parentFile, "${file.name}.part") - client.prepareGet("$TILE_ENDPOINT/$z/$x/$y.png").execute { response -> - if (!response.status.isSuccess()) error("HTTP ${response.status.value}") - val type = response.headers[HttpHeaders.ContentType].orEmpty() - if (!type.startsWith("image/")) error("unexpected content type $type") - val channel = response.bodyAsChannel() - val buffer = ByteArray(16 * 1024) - var total = 0 - FileOutputStream(partial).use { output -> - while (true) { - val read = channel.readAvailable(buffer) - if (read == -1) break - if (read == 0) continue - total += read - if (total > MAX_TILE_BYTES) error("tile larger than expected") - output.write(buffer, 0, read) + val lockKey = file.absoluteFile.normalize() + val downloadLock = synchronized(downloadLocks) { + downloadLocks.getOrPut(lockKey) { TileLock() }.also { it.users++ } + } + try { + downloadLock.mutex.withLock { + if (file.isFile && System.currentTimeMillis() - file.lastModified() < TTL_MS) { + return@withLock file + } + gate.withPermit { + val partial = File(file.parentFile, "${file.name}.part") + try { + file.parentFile?.mkdirs() + client.prepareGet("$TILE_ENDPOINT/$z/$x/$y.png").execute { response -> + if (!response.status.isSuccess()) error("HTTP ${response.status.value}") + val type = response.headers[HttpHeaders.ContentType].orEmpty() + if (!type.startsWith("image/")) error("unexpected content type $type") + val channel = response.bodyAsChannel() + val buffer = ByteArray(16 * 1024) + var total = 0 + FileOutputStream(partial).use { output -> + while (true) { + val read = channel.readAvailable(buffer) + if (read == -1) break + if (read == 0) continue + total += read + if (total > MAX_TILE_BYTES) error("tile larger than expected") + output.write(buffer, 0, read) + } + } } + currentCoroutineContext().ensureActive() + if (partial.length() < MIN_TILE_BYTES) error("tile too small") + if (!partial.renameTo(file)) error("cannot publish tile") + file.setLastModified(System.currentTimeMillis()) + trimOldest() + file + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + null + } finally { + partial.delete() } } - if (partial.length() < MIN_TILE_BYTES) error("tile too small") - if (!partial.renameTo(file)) error("cannot publish tile") - file.setLastModified(System.currentTimeMillis()) - trimOldest() - file - }.getOrNull() + } + } finally { + synchronized(downloadLocks) { + if (--downloadLock.users == 0) downloadLocks.remove(lockKey) + } } } @@ -73,7 +100,10 @@ class MapTileStore( private fun tileFile(z: Int, x: Int, y: Int) = File(root, "$z/$x/$y.png") + private class TileLock(val mutex: Mutex = Mutex(), var users: Int = 0) + companion object { + private val downloadLocks = mutableMapOf() const val TILE_ENDPOINT = "https://tile.openstreetmap.org" const val USER_AGENT = "Monogram/1.0 (Android; +https://github.com/monogram-android/monogram)" const val MAX_CONCURRENT = 2 diff --git a/network/http/src/main/java/org/monogram/network/http/MediaRepository.kt b/network/http/src/main/java/org/monogram/network/http/MediaRepository.kt index 3417f1b54..330255565 100644 --- a/network/http/src/main/java/org/monogram/network/http/MediaRepository.kt +++ b/network/http/src/main/java/org/monogram/network/http/MediaRepository.kt @@ -2,6 +2,7 @@ package org.monogram.network.http import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -19,11 +20,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -42,6 +41,8 @@ import org.monogram.core.models.Message import org.monogram.core.models.PeerId import org.monogram.core.models.ScheduledMessage import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.PriorityQueue import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger @@ -58,11 +59,13 @@ fun peerPhotoSmallCacheKey(photoCacheKey: String): String = internal fun streamPrefixPart( directory: File, cacheKey: String, - offset: Long -): File = - FileCache(directory, maintainOnInit = false).fileFor(streamPartKey(cacheKey, offset)) + offset: Long, +): File = FileCache(directory, maintainOnInit = false).fileFor(streamPartKey(cacheKey, offset)) -internal fun streamPartKey(cacheKey: String, offset: Long): String = "$cacheKey:stream:v2:$offset" +internal fun streamPartKey( + cacheKey: String, + offset: Long, +): String = "$cacheKey:stream:v2:$offset" internal fun streamMediaKey(message: Message): String? = message.mediaCacheKey?.let { "$it:chat:${message.id.chatId.value}:message:${message.id.id}:size:${message.fileSize ?: 0}" @@ -72,9 +75,9 @@ internal fun streamMediaKey(message: Message): String? = message.mediaCacheKey?. internal fun reusableStreamPrefix( file: File?, offset: Long = 0, - size: Long? = null + size: Long? = null, ): File? { - val expected = size?.takeIf { it > 0 }?.let { minOf(512L * 1024, it - offset) } ?: 512L * 1024 + val expected = size?.takeIf { it > 0 }?.let { minOf(512L * 1024, it - offset) } ?: (512L * 1024) return file?.takeIf { expected > 0 && it.isFile && it.length() == expected } } @@ -106,12 +109,27 @@ fun interface TelegramMediaFetcher { priority: Int, ): Outcome + suspend fun fetchExpectedMedia( + chatId: PeerId, + messageId: Int, + destPath: String, + kind: MediaFetchKind, + priority: Int, + expectedCacheKey: String, + ): Outcome = fetchMessageMedia(chatId, messageId, destPath, kind, priority) + /** A visible consumer joined the transfer writing [destPath]. */ - fun promote(destPath: String, priority: Int) = Unit + fun promote( + destPath: String, + priority: Int, + ) = Unit } fun interface TelegramInlineThumbPeek { - fun peek(chatId: PeerId, messageId: Int): ByteArray? + fun peek( + chatId: PeerId, + messageId: Int, + ): ByteArray? } fun interface TelegramPeerInlineThumbPeek { @@ -119,7 +137,7 @@ fun interface TelegramPeerInlineThumbPeek { } class MediaRepository( - cacheRoot: File, + val cacheRoot: File, private val httpClientFactory: () -> io.ktor.client.HttpClient = { HttpModule.createClient() }, private val telegramFetcher: TelegramMediaFetcher? = null, private val customEmojiFetcher: (suspend (Long, String, Int) -> Outcome)? = null, @@ -135,12 +153,13 @@ class MediaRepository( val mapTiles = MapTileStore(cacheRoot, httpClientFactory) @OptIn(UnstableApi::class) - fun createMessageDataSourceFactory(message: Message): androidx.media3.datasource.DataSource.Factory = + fun createMessageDataSourceFactory(message: Message): DataSource.Factory = TelegramVideoDataSource.Factory(message, streamRoot, telegramChunkFetcher, cache) @OptIn(UnstableApi::class) - fun createCachedDataSourceFactory(key: String): androidx.media3.datasource.DataSource.Factory = - androidx.media3.datasource.DataSource.Factory { LeasedFileDataSource(cache, key) } + fun createCachedDataSourceFactory(key: String): DataSource.Factory = + DataSource.Factory { LeasedFileDataSource(cache, key) } + private val cache = FileCache( cacheRoot, maxBytes = cacheLimitBytes ?: 1L, @@ -174,7 +193,9 @@ class MediaRepository( private val nextConsumerId = java.util.concurrent.atomic.AtomicLong() private val telegramWorkers = mutableListOf() private var telegramSequence = 0L - @Volatile private var telegramStopped = false + + @Volatile + private var telegramStopped = false private val progressPaths = ConcurrentHashMap() val downloadProgress: StateFlow> = progress.asStateFlow() @@ -184,16 +205,19 @@ class MediaRepository( fun isUserDownload(key: String): Boolean = key in userDownloadsState.value - fun protectCacheKeys(owner: String, keys: Collection) { + fun protectCacheKeys( + owner: String, + keys: Collection, + ) { cache.setProtectedKeys(owner, keys) } + private val inlineThumbs = java.util.LinkedHashMap(INLINE_THUMB_CAP, 0.75f, true) fun inlineThumbJpeg(message: Message): ByteArray? { - val key = message.thumbCacheKey - ?: message.mediaCacheKey?.let(::mediaThumbCacheKey) - ?: return null + val key = + message.thumbCacheKey ?: message.mediaCacheKey?.let(::mediaThumbCacheKey) ?: return null cachedInlineThumb(key)?.let { return it } val bytes = inlineThumbPeek?.peek(message.id.chatId, message.id.id) ?: return null if (bytes.isNotEmpty()) { @@ -204,12 +228,15 @@ class MediaRepository( } /** Stripped or cached avatar JPEG indexed at `(peerId, messageId = 0)`. No download. */ - fun peerInlineThumbJpeg(peerId: PeerId): ByteArray? { - val key = "${avatarPeerKey(peerId)}:thumb" - cachedInlineThumb(key)?.let { return it } + fun peerInlineThumbJpeg( + peerId: PeerId, + cacheKey: String? = null, + ): ByteArray? { + val key = cacheKey?.let { "$it:thumb" } + key?.let(::cachedInlineThumb)?.let { return it } val bytes = peerInlineThumbPeek?.peek(peerId) ?: return null if (bytes.isNotEmpty()) { - rememberInlineThumb(key, bytes) + if (key != null) rememberInlineThumb(key, bytes) PerfLog.event("stripped_peer") } return bytes.takeIf { it.isNotEmpty() } @@ -219,7 +246,10 @@ class MediaRepository( inlineThumbs[key] } - private fun rememberInlineThumb(key: String, bytes: ByteArray) = synchronized(inlineThumbs) { + private fun rememberInlineThumb( + key: String, + bytes: ByteArray, + ) = synchronized(inlineThumbs) { inlineThumbs[key] = bytes dropEldest(inlineThumbs, INLINE_THUMB_CAP) } @@ -240,32 +270,34 @@ class MediaRepository( * Key entries live only while a UI collector needs them, so scrolling through * media cannot retain one state flow per cache file for the app lifetime. */ - fun cacheGeneration(keys: Iterable): Flow { - val observed = keys.asSequence() - .filter(String::isNotBlank) - .distinct() - .map(::observeCacheGeneration) - .plus(cacheClearedGeneration) - .map { it.drop(1) } - .toList() - return merge(*observed.toTypedArray()).runningFold(0L) { version, _ -> version + 1 } - } - - fun cacheGeneration(key: String): Flow = cacheGeneration(listOf(key)) - - private fun observeCacheGeneration(key: String): Flow = flow { - val entry = keyedGenerations.compute(key) { _, current -> - (current ?: KeyedGeneration()).also { it.collectors.incrementAndGet() } - }!! + fun cacheGeneration(keys: Iterable): Flow = flow { + val names = keys.asSequence().filter(String::isNotBlank).distinct().toList() + val observed = names.map { key -> + key to keyedGenerations.compute(key) { _, current -> + (current ?: KeyedGeneration()).also { it.collectors.incrementAndGet() } + }!! + }.toList() try { - emitAll(entry.generation) + val sources = observed.map { it.second.generation } + cacheClearedGeneration + val baselines = sources.map { it.value } + var version = generation.value + emit(version) + merge( + *sources.zip(baselines).map { (source, baseline) -> + source.dropWhile { it == baseline } + }.toTypedArray(), + ).collect { emit(++version) } } finally { - keyedGenerations.computeIfPresent(key) { _, current -> - if (current === entry && entry.collectors.decrementAndGet() == 0) null else current + observed.forEach { (key, entry) -> + keyedGenerations.computeIfPresent(key) { _, current -> + if (current === entry && entry.collectors.decrementAndGet() == 0) null else current + } } } } + fun cacheGeneration(key: String): Flow = cacheGeneration(listOf(key)) + private class KeyedGeneration { val generation = MutableStateFlow(0L) val collectors = AtomicInteger(0) @@ -302,16 +334,39 @@ class MediaRepository( cache.remove(key).also { removed -> if (removed) markCached(key) } } + suspend fun cacheThumbnail( + key: String, + bytes: ByteArray, + chatId: Long, + mediaKind: String?, + ): File? = withContext(Dispatchers.IO) { + runCatching { + cache.put( + key, + bytes, + chatId, + FileCache.inferKind(key, mediaKind), + ) + }.onSuccess { markCached(key) }.getOrNull() + } + fun cachedAvatar(peerId: PeerId): File? = cache.get(avatarPeerKey(peerId)) private fun avatarPeerKey(peerId: PeerId): String = "avatar:${peerId.value}" - private fun rememberPeerAvatar(peerId: PeerId, file: File) { + private fun rememberPeerAvatar( + peerId: PeerId, + file: File, + ) { val alias = avatarPeerKey(peerId) val existing = cache.get(alias) if (existing != null) { if (existing.canonicalFile == file.canonicalFile) return - runCatching { cache.putFile(alias, file, peerId.value, FileCache.KIND_PHOTOS) } + runCatching { + cache.putFile( + alias, file, peerId.value, FileCache.KIND_PHOTOS + ) + }.onSuccess { markCached(alias) } return } runCatching { @@ -321,6 +376,7 @@ class MediaRepository( fun clearCache(): Long { val freed = cache.clear() + synchronized(inlineThumbs) { inlineThumbs.clear() } markAllCachedFilesChanged() return freed } @@ -363,18 +419,19 @@ class MediaRepository( scheduled: ScheduledMessage, priority: Int = MediaPriority.VISIBLE, ): Outcome = ensureMessageMedia( - scheduled.message.copy(id = scheduled.mediaMessageId), priority, - retain = false, scheduled = true + scheduled.message.copy(id = scheduled.mediaMessageId), + priority, + retain = false, + scheduled = true, ) private suspend fun ensureMessageMedia( message: Message, priority: Int, retain: Boolean, - scheduled: Boolean + scheduled: Boolean, ): Outcome { - val key = message.mediaCacheKey - ?: return Outcome.Err("message has no media key") + val key = message.mediaCacheKey ?: return Outcome.Err("message has no media key") return ensureLocalTelegramMedia( key = key, scheduled = scheduled, @@ -429,11 +486,12 @@ class MediaRepository( } val temporary = File.createTempFile("stream-$offset-", ".part", streamRoot) try { - when (val result = fetcher.fetch( + when (val result = fetcher.fetchExpected( message.id.chatId, message.id.id, temporary.absolutePath, - offset + offset, + checkNotNull(message.mediaCacheKey), )) { is Outcome.Err -> { temporary.delete() @@ -443,11 +501,11 @@ class MediaRepository( is Outcome.Ok -> { val fetched = File(result.value) val bytes = fetched.length() - if (fetched.canonicalFile != temporary.canonicalFile || - bytes <= 0L || bytes > STREAM_PART || - (message.fileSize?.takeIf { it > 0 } != null && - reusableStreamPrefix(fetched, offset, message.fileSize) == null) - ) { + if (fetched.canonicalFile != temporary.canonicalFile || bytes <= 0L || bytes > STREAM_PART || (message.fileSize?.takeIf { it > 0 } != null && reusableStreamPrefix( + fetched, + offset, + message.fileSize, + ) == null)) { return last?.let { Outcome.Ok(it) } ?: Outcome.Err("invalid stream prefix") } @@ -460,8 +518,10 @@ class MediaRepository( FileCache.KIND_VIDEOS, ) } catch (error: Exception) { - return last?.let { Outcome.Ok(it) } - ?: Outcome.Err(error.message ?: "stream prefix publish", error) + return last?.let { Outcome.Ok(it) } ?: Outcome.Err( + error.message ?: "stream prefix publish", + error, + ) } last = file if (bytes < STREAM_PART) return Outcome.Ok(file) @@ -487,12 +547,13 @@ class MediaRepository( private suspend fun prefetchMp4Metadata( message: Message, key: String, - fetcher: TelegramChunkFetcher + fetcher: TelegramChunkFetcher, ) { val size = message.fileSize?.takeIf { it > 0 } ?: return var remaining = 2L * 1024 * 1024 val owner = "metadata:${java.util.UUID.randomUUID()}" val protected = LinkedHashSet() + suspend fun part(offset: Long): File? { val aligned = offset / STREAM_PART * STREAM_PART val partKey = streamPartKey(key, aligned) @@ -505,11 +566,12 @@ class MediaRepository( remaining -= charge val temporary = File.createTempFile("metadata-", ".part", streamRoot) try { - when (val result = fetcher.fetch( + when (val result = fetcher.fetchExpected( message.id.chatId, message.id.id, temporary.absolutePath, - aligned + aligned, + checkNotNull(message.mediaCacheKey), )) { is Outcome.Err -> return null is Outcome.Ok -> if (File(result.value).canonicalFile != temporary.canonicalFile) return null @@ -521,7 +583,7 @@ class MediaRepository( partKey, temporary, message.id.chatId.value, - FileCache.KIND_VIDEOS + FileCache.KIND_VIDEOS, ) }.getOrNull() } finally { @@ -537,7 +599,7 @@ class MediaRepository( val file = part(position) ?: break val take = minOf( (count - copied).toLong(), - file.length() - (position % STREAM_PART) + file.length() - (position % STREAM_PART), ).toInt() if (take <= 0) break java.io.RandomAccessFile(file, "r").use { input -> @@ -552,7 +614,8 @@ class MediaRepository( var complete = true while (position < offset + length) { if (part(position) == null) { - complete = false; break + complete = false + break } position = (position / STREAM_PART + 1) * STREAM_PART } @@ -575,21 +638,22 @@ class MediaRepository( ): Outcome = ensureMessageThumb( scheduled.message.copy(id = scheduled.mediaMessageId), priority, - scheduled = true + scheduled = true, ) private suspend fun ensureMessageThumb( message: Message, priority: Int, - scheduled: Boolean + scheduled: Boolean, ): Outcome { val distinctThumb = message.thumbCacheKey?.takeUnless { it == message.mediaCacheKey } if (distinctThumb == null && message.mediaKind in NO_SYNTHETIC_THUMB_KINDS) { return Outcome.Err("no downloadable thumb") } - val key = distinctThumb - ?: message.mediaCacheKey?.let(::mediaThumbCacheKey) - ?: return Outcome.Err("message has no media key") + val key = + distinctThumb ?: message.mediaCacheKey?.let(::mediaThumbCacheKey) ?: return Outcome.Err( + "message has no media key", + ) return ensureLocalTelegramMedia( key = key, chatId = message.id.chatId, @@ -606,67 +670,77 @@ class MediaRepository( peerId: PeerId, cacheKey: String, priority: Int = MediaPriority.DEFAULT, + allowStaleFallback: Boolean = true, ): Outcome { if (isVideoAvatarKey(cacheKey)) { - return fetchFullAvatar(peerId, cacheKey, priority) + return fetchFullAvatar(peerId, cacheKey, priority, allowStaleFallback) } cache.get(cacheKey)?.let { file -> rememberPeerAvatar(peerId, file) return Outcome.Ok(file) } val smallKey = peerPhotoSmallCacheKey(cacheKey) + val fullPriority = if (allowStaleFallback) MediaPriority.IDLE else priority cache.get(smallKey)?.let { file -> - scheduleFullAvatar(peerId, cacheKey) + scheduleFullAvatar(peerId, cacheKey, fullPriority) return Outcome.Ok(file) } - cachedAvatar(peerId)?.let { file -> - scheduleFullAvatar(peerId, cacheKey) - return Outcome.Ok(file) + if (allowStaleFallback) { + cachedAvatar(peerId)?.let { file -> + scheduleFullAvatar(peerId, cacheKey, fullPriority) + return Outcome.Ok(file) + } } - when ( - val small = ensureLocalTelegramMedia( - key = smallKey, - chatId = peerId, - messageId = 0, - kind = MediaFetchKind.Display, - priority = priority, - ) - ) { + when (val small = ensureLocalTelegramMedia( + key = smallKey, + chatId = peerId, + messageId = 0, + kind = MediaFetchKind.Display, + priority = priority, + )) { is Outcome.Ok -> { - scheduleFullAvatar(peerId, cacheKey) + scheduleFullAvatar(peerId, cacheKey, fullPriority) return small } - is Outcome.Err -> Unit + is Outcome.Err -> { + Unit + } } - return fetchFullAvatar(peerId, cacheKey, priority) + return fetchFullAvatar(peerId, cacheKey, priority, allowStaleFallback) } private suspend fun fetchFullAvatar( peerId: PeerId, cacheKey: String, priority: Int, - ): Outcome = when ( - val fetched = ensureLocalTelegramMedia( - key = cacheKey, - chatId = peerId, - messageId = 0, - kind = MediaFetchKind.Full, - priority = priority, - ) - ) { + allowStaleFallback: Boolean, + ): Outcome = when (val fetched = ensureLocalTelegramMedia( + key = cacheKey, + chatId = peerId, + messageId = 0, + kind = MediaFetchKind.Full, + priority = priority, + )) { is Outcome.Ok -> { rememberPeerAvatar(peerId, fetched.value) fetched } is Outcome.Err -> { - if (isVideoAvatarKey(cacheKey)) fetched - else cachedAvatar(peerId)?.let { Outcome.Ok(it) } ?: fetched + if (isVideoAvatarKey(cacheKey) || !allowStaleFallback) { + fetched + } else { + cachedAvatar(peerId)?.let { Outcome.Ok(it) } ?: fetched + } } } - private fun scheduleFullAvatar(peerId: PeerId, cacheKey: String) { + private fun scheduleFullAvatar( + peerId: PeerId, + cacheKey: String, + priority: Int, + ) { if (isVideoAvatarKey(cacheKey) || cache.get(cacheKey) != null) return scope.launch { if (cache.get(cacheKey) != null) return@launch @@ -675,7 +749,7 @@ class MediaRepository( chatId = peerId, messageId = 0, kind = MediaFetchKind.Full, - priority = MediaPriority.IDLE, + priority = priority, ) if (fetched is Outcome.Ok) rememberPeerAvatar(peerId, fetched.value) } @@ -685,12 +759,18 @@ class MediaRepository( MediaConsumer(nextConsumerId.incrementAndGet(), key) /** Changes this consumer's priority on the existing transfer. Does not join another job. */ - fun updateDownloadConsumer(consumer: MediaConsumer, priority: Int) { + fun updateDownloadConsumer( + consumer: MediaConsumer, + priority: Int, + ) { val revision = consumer.revision.incrementAndGet() scope.launch { applyDownloadConsumerPriority(consumer, priority, revision) } } - internal suspend fun awaitDownloadConsumerPriority(consumer: MediaConsumer, priority: Int) { + internal suspend fun awaitDownloadConsumerPriority( + consumer: MediaConsumer, + priority: Int, + ) { val revision = consumer.revision.incrementAndGet() applyDownloadConsumerPriority(consumer, priority, revision) } @@ -722,35 +802,42 @@ class MediaRepository( cache.get(key)?.let { return Outcome.Ok(it) } val result = enqueueTelegram(key, priority, chatId = null, consumer = consumer) { activePriority -> - cache.get(key)?.let { return@enqueueTelegram Outcome.Ok(it) } - val dest = cache.fileFor(key) - val fetcher = customEmojiFetcher - ?: return@enqueueTelegram Outcome.Err("custom emoji fetcher not configured") - when (val fetched = fetcher(documentId, dest.absolutePath, activePriority)) { - is Outcome.Ok -> { - val file = File(fetched.value) - if (!file.exists()) { - Outcome.Err("custom emoji download produced no file") - } else { - runCatching { - cache.putFile( - key, - file, - chatId = null, - kind = FileCache.KIND_STICKERS, - ) - }.fold( - onSuccess = { - markCached(key) - Outcome.Ok(it) - }, - onFailure = { Outcome.Err(it.message ?: "cache write failed") }, - ) + cache.get(key)?.let { return@enqueueTelegram Outcome.Ok(it) } + val fetcher = customEmojiFetcher + ?: return@enqueueTelegram Outcome.Err("custom emoji fetcher not configured") + val dest = File.createTempFile("emoji-", ".part", cacheRoot) + try { + when (val fetched = fetcher(documentId, dest.absolutePath, activePriority)) { + is Outcome.Ok -> { + val file = File(fetched.value) + if (!file.exists()) { + Outcome.Err("custom emoji download produced no file") + } else { + runCatching { + cache.putFile( + key, + file, + chatId = null, + kind = FileCache.KIND_STICKERS, + ) + }.fold( + onSuccess = { + markCached(key) + Outcome.Ok(it) + }, + onFailure = { Outcome.Err(it.message ?: "cache write failed") }, + ) + } + } + + is Outcome.Err -> { + fetched + } } + } finally { + dest.delete() } - is Outcome.Err -> fetched } - } if (result is Outcome.Err) { AppLog.api("media", "err kind=Emoji key=emoji:$documentId ${result.message}") } @@ -810,16 +897,24 @@ class MediaRepository( val fetcher = telegramFetcher ?: return@enqueueTelegram Outcome.Err("telegram media fetcher not configured") val destPath = part.absolutePath + val reservation = try { + totalBytes?.takeIf { it > 0L }?.let { + cache.reserve(key, it, optional = false) + } + } catch (error: FileCache.AdmissionException) { + return@enqueueTelegram Outcome.Err( + error.message ?: "cache reservation refused" + ) + } progressPaths[destPath] = key - val reservation = totalBytes?.takeIf { it > 0L } - ?.let { runCatching { cache.reserve(key, it, optional = false) }.getOrNull() } val fetched = try { - fetcher.fetchMessageMedia( + fetcher.fetchExpectedMedia( chatId = chatId, messageId = messageId, destPath = destPath, kind = kind, priority = activePriority, + expectedCacheKey = key, ) } finally { progressPaths.remove(destPath) @@ -850,14 +945,15 @@ class MediaRepository( ) } } + is Outcome.Err -> { AppLog.api( "media", "err kind=${kind.name} key=$key ${fetched.message} chat=${chatId.value} id=$messageId", ) val err = fetched.telegramError - val keepPartial = err.kind == TelegramError.Kind.Flood || - err.kind == TelegramError.Kind.Network + val keepPartial = + err.kind == TelegramError.Kind.Flood || err.kind == TelegramError.Kind.Network if (!keepPartial) { part.delete() clearProgress(key) @@ -900,10 +996,7 @@ class MediaRepository( ) { val keys = telegramMutex.withLock { val matched = telegramJobs.values.filter { job -> - job.chatId == chatId.value && - job.priority < belowPriority && - job.key !in keepKeys && - !job.deferred.isCompleted + job.chatId == chatId.value && job.priority < belowPriority && job.key !in keepKeys && !job.deferred.isCompleted } matched.forEach { cancelJobLocked(it) } matched.map { it.key } @@ -933,7 +1026,12 @@ class MediaRepository( } private fun markCancelled(key: String) { - runCatching { cancelMarker(key).apply { parentFile?.mkdirs(); writeText("") } } + runCatching { + cancelMarker(key).apply { + parentFile?.mkdirs() + writeText("") + } + } } private fun cancelJobLocked(job: TelegramJob) { @@ -957,15 +1055,13 @@ class MediaRepository( telegramPending += QueuedTelegram(job) } - private fun hasPendingAtLeastLocked(minPriority: Int, thumbnail: Boolean? = null): Boolean = - telegramPending.any { queued -> - val job = queued.job - queued.generation == job.generation && - !job.running && - !job.cancelled && - !job.deferred.isCompleted && - job.priority >= minPriority && (thumbnail == null || job.thumbnail == thumbnail) - } + private fun hasPendingAtLeastLocked( + minPriority: Int, + thumbnail: Boolean? = null, + ): Boolean = telegramPending.any { queued -> + val job = queued.job + queued.generation == job.generation && !job.running && !job.cancelled && !job.deferred.isCompleted && job.priority >= minPriority && (thumbnail == null || job.thumbnail == thumbnail) + } private suspend fun applyDownloadConsumerPriority( consumer: MediaConsumer, @@ -1011,7 +1107,10 @@ class MediaRepository( publishSlotPriorityLocked(job) } - private fun detachSlotLocked(job: TelegramJob, consumer: MediaConsumer) { + private fun detachSlotLocked( + job: TelegramJob, + consumer: MediaConsumer, + ) { val slot = job.slots[consumer.id] ?: return slot.holds -= 1 job.consumers-- @@ -1081,8 +1180,9 @@ class MediaRepository( telegramFetcher?.promote( File( dest.parentFile, - "${dest.name}.part" - ).absolutePath, priority + "${dest.name}.part", + ).absolutePath, + priority, ) } } @@ -1130,8 +1230,15 @@ class MediaRepository( if (job.cancelled) { job.deferred.await() return enqueueTelegram( - key, priority, chatId = chatId, name = name, totalBytes = totalBytes, - large = large, thumbnail = thumbnail, consumer = bound, work = work, + key, + priority, + chatId = chatId, + name = name, + totalBytes = totalBytes, + large = large, + thumbnail = thumbnail, + consumer = bound, + work = work, ) } return job.deferred.await() @@ -1220,9 +1327,10 @@ class MediaRepository( val result = perfOp("telegram_job") { try { coroutineScope { - val runner = - async(PerfLog.OpTrace(job.opId.takeIf { it != 0L } ?: PerfLog.newOpId()), - start = CoroutineStart.LAZY) { job.work(job.priority) } + val runner = async( + PerfLog.OpTrace(job.opId.takeIf { it != 0L } ?: PerfLog.newOpId()), + start = CoroutineStart.LAZY, + ) { job.work(job.priority) } job.runner = runner try { runner.start() @@ -1263,8 +1371,7 @@ class MediaRepository( thumbWake.trySend(Unit) } - private fun runningLargeLocked(): Int = - telegramJobs.values.count { it.running && it.large } + private fun runningLargeLocked(): Int = telegramJobs.values.count { it.running && it.large } /** * File the explicit-download UI opens. It lives outside the evictable cache, @@ -1274,18 +1381,39 @@ class MediaRepository( val source = cache.get(key) ?: return null cache.pin(key) val parent = streamRoot.parentFile ?: return source - val dir = File(parent, "downloads").apply { mkdirs() } - val safe = key.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80).ifBlank { "file" } - val dest = File(dir, safe) - val copied = runCatching { + var temporary: File? = null + return try { + val dir = File(parent, "downloads").apply { mkdirs() } + val dest = File(dir, cache.fileFor(key).name) + if (dest.isFile && dest.length() == source.length()) return dest + val staged = File.createTempFile("retained-", ".part", dir) + temporary = staged source.inputStream().use { input -> - dest.outputStream().use { output -> input.copyTo(output) } + staged.outputStream().use { output -> input.copyTo(output) } } - }.isSuccess - return if (copied) dest else source + if (android.os.Build.VERSION.SDK_INT >= 26) { + Files.move( + staged.toPath(), + dest.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } else { + check(staged.renameTo(dest)) { "retained download replacement failed" } + } + dest + } catch (_: Exception) { + source + } finally { + temporary?.delete() + } } - private fun trackUserDownloadLocked(key: String, name: String?, totalBytes: Long?) { + private fun trackUserDownloadLocked( + key: String, + name: String?, + totalBytes: Long?, + ) { val label = name?.trim().orEmpty().ifEmpty { key } userDownloadsState.update { current -> current + (key to UserDownload(key = key, name = label, totalBytes = totalBytes)) @@ -1313,33 +1441,39 @@ class MediaRepository( source: File, chatId: Long?, mediaKind: String?, - ): Outcome { - return runCatching { - cache.putFile( - key, - source, - chatId = chatId, - kind = FileCache.inferKind(key, mediaKind), - ) - }.fold( - onSuccess = { - if (source.canonicalFile != dest.canonicalFile) source.delete() - markCached(key) - Outcome.Ok(it) - }, - onFailure = { Outcome.Err(it.message ?: "cache write failed") }, + ): Outcome = runCatching { + cache.putFile( + key, + source, + chatId = chatId, + kind = FileCache.inferKind(key, mediaKind), ) - } + }.fold( + onSuccess = { + if (source.canonicalFile != dest.canonicalFile) source.delete() + markCached(key) + Outcome.Ok(it) + }, + onFailure = { Outcome.Err(it.message ?: "cache write failed") }, + ) - fun onNativeProgress(destPath: String, downloaded: Long) { + fun onNativeProgress( + destPath: String, + downloaded: Long, + ) { if (downloaded <= 0L) return - val key = progressPaths[destPath] - ?: progressPaths.entries.firstOrNull { destPath.contains(File(it.key).name) }?.value - ?: return + val key = progressPaths[destPath] ?: progressPaths.entries.firstOrNull { + destPath.contains( + File(it.key).name, + ) + }?.value ?: return setProgress(key, maxOf(progressBytes(key), downloaded)) } - private fun setProgress(key: String, bytes: Long) { + private fun setProgress( + key: String, + bytes: Long, + ) { progress.update { current -> if (current[key] == bytes) current else current + (key to bytes) } @@ -1351,7 +1485,10 @@ class MediaRepository( } } - private class ConsumerSlot(var priority: Int, var holds: Int) + private class ConsumerSlot( + var priority: Int, + var holds: Int, + ) private class TelegramJob( val key: String, @@ -1394,6 +1531,7 @@ class MediaRepository( const val MAX_LARGE_PIPELINES = 2 const val STREAM_PART = 512L * 1024 const val STREAM_PREFIX_BYTES = 2L * 1024 * 1024 + @Volatile var progressSink: ((String, Long) -> Unit)? = null @@ -1401,7 +1539,10 @@ class MediaRepository( } } -internal fun dropEldest(map: MutableMap, cap: Int) { +internal fun dropEldest( + map: MutableMap, + cap: Int, +) { while (map.size > cap) { val eldest = map.keys.firstOrNull() ?: return map.remove(eldest) diff --git a/network/http/src/main/java/org/monogram/network/http/TelegramVideoDataSource.kt b/network/http/src/main/java/org/monogram/network/http/TelegramVideoDataSource.kt index 2a607a541..8257b2994 100644 --- a/network/http/src/main/java/org/monogram/network/http/TelegramVideoDataSource.kt +++ b/network/http/src/main/java/org/monogram/network/http/TelegramVideoDataSource.kt @@ -18,7 +18,20 @@ import java.io.IOException import java.io.RandomAccessFile fun interface TelegramChunkFetcher { - suspend fun fetch(chatId: PeerId, messageId: Int, destPath: String, offset: Long): Outcome + suspend fun fetch( + chatId: PeerId, + messageId: Int, + destPath: String, + offset: Long, + ): Outcome + + suspend fun fetchExpected( + chatId: PeerId, + messageId: Int, + destPath: String, + offset: Long, + expectedCacheKey: String, + ): Outcome = fetch(chatId, messageId, destPath, offset) } /** One aligned 512 KiB part on disk; reads stream from it instead of a heap copy. */ @@ -48,6 +61,7 @@ class TelegramVideoDataSource private constructor( private var partIsPrefix = false private var partHandle: RandomAccessFile? = null private var partLease: FileCache.Lease? = null + @Volatile private var request: Job? = null override fun open(dataSpec: DataSpec): Long { @@ -56,11 +70,16 @@ class TelegramVideoDataSource private constructor( val size = message.fileSize?.takeIf { it > 0 } if (size != null && dataSpec.position > size) throw IOException("Position exceeds media size") position = dataSpec.position - remaining = when { - size != null && dataSpec.length != C.LENGTH_UNSET.toLong() -> minOf(size - position, dataSpec.length) - size != null -> size - position - else -> dataSpec.length - } + remaining = + when { + size != null && dataSpec.length != C.LENGTH_UNSET.toLong() -> minOf( + size - position, + dataSpec.length + ) + + size != null -> size - position + else -> dataSpec.length + } releasePart() request = Job() uri = dataSpec.uri @@ -68,7 +87,11 @@ class TelegramVideoDataSource private constructor( return remaining } - override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { if (length == 0) return 0 if (remaining == 0L) return C.RESULT_END_OF_INPUT val active = request ?: throw IOException("Media source closed") @@ -95,7 +118,10 @@ class TelegramVideoDataSource private constructor( return read } - private fun openPart(offset: Long, active: Job) { + private fun openPart( + offset: Long, + active: Job, + ) { if (!directory.isDirectory && !directory.mkdirs()) throw IOException("Cannot create media buffer") releasePart() val mediaKey = streamMediaKey(message) @@ -112,14 +138,26 @@ class TelegramVideoDataSource private constructor( try { if (reused == null) { runBlocking(active) { - when (val result = checkNotNull(fetcher).fetch( - message.id.chatId, - message.id.id, - file.absolutePath, - offset - )) { - is Outcome.Err -> throw IOException(result.message) - is Outcome.Ok -> Unit + when ( + val result = + checkNotNull(fetcher).fetchExpected( + message.id.chatId, + message.id.id, + file.absolutePath, + offset, + message.mediaCacheKey + ?: throw IOException("Media identity unavailable"), + ) + ) { + is Outcome.Err -> { + throw IOException(result.message) + } + + is Outcome.Ok -> { + if (File(result.value).canonicalFile != file.canonicalFile) { + throw IOException("Unexpected media part path") + } + } } } } @@ -131,19 +169,21 @@ class TelegramVideoDataSource private constructor( if (key != null && message.fileSize?.takeIf { it > 0 } != null && reusableStreamPrefix( file, offset, - message.fileSize - ) == null) { + message.fileSize, + ) == null + ) { throw IOException("Incomplete media part") } if (reused == null) RandomAccessFile(file, "rw").use { it.fd.sync() } if (reused == null && key != null && cache != null) { try { - partLease = cache.publishLeased( - key, - file, - message.id.chatId.value, - FileCache.KIND_VIDEOS - ) + partLease = + cache.publishLeased( + key, + file, + message.id.chatId.value, + FileCache.KIND_VIDEOS, + ) } catch (error: FileCache.AdmissionException) { if (error.reason != FileCache.AdmissionFailure.InUse) throw error val concurrent = cache.acquire(key) diff --git a/network/http/src/test/java/org/monogram/network/http/MediaRepositoryThumbTest.kt b/network/http/src/test/java/org/monogram/network/http/MediaRepositoryThumbTest.kt index d51efcce2..ac27b6dc4 100644 --- a/network/http/src/test/java/org/monogram/network/http/MediaRepositoryThumbTest.kt +++ b/network/http/src/test/java/org/monogram/network/http/MediaRepositoryThumbTest.kt @@ -3,6 +3,7 @@ package org.monogram.network.http import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch @@ -10,6 +11,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -25,238 +27,396 @@ class MediaRepositoryThumbTest { val tmp = TemporaryFolder() @Test - fun ensureLocalMessageThumbAlwaysRequestsThumb() = runBlocking { - var lastThumb: Boolean? = null - val repo = MediaRepository( - cacheRoot = tmp.newFolder("cache"), - telegramFetcher = TelegramMediaFetcher { _, _, destPath, kind, _ -> - lastThumb = kind == MediaFetchKind.Thumb - File(destPath).writeBytes(byteArrayOf(7, 7, 7)) - Outcome.Ok(destPath) - }, - ) - val message = Message( - id = MessageId(PeerId(1), 9), - senderId = null, - text = null, - date = 0L, - outgoing = false, - mediaCacheKey = "doc:1:thumb", - thumbCacheKey = "doc:1:thumb", - ) - val result = repo.ensureLocalMessageThumb(message) - assertTrue(result is Outcome.Ok) - assertEquals(true, lastThumb) - } + fun publishedThumbnailIsAccountedAndClearedByChat() = + runBlocking { + val repo = MediaRepository(cacheRoot = tmp.newFolder("notification-thumb")) + val key = "notification:12:7:thumb" + val file = repo.cacheThumbnail(key, byteArrayOf(1, 2, 3), 12L, "photo") + + assertTrue(file?.isFile == true) + assertEquals(3L, repo.cacheByChat()[12L]) + assertEquals(3L, repo.cacheByKind()[FileCache.KIND_PHOTOS]) + assertEquals(3L, repo.clearChatCache(12L)) + assertNull(repo.cachedFile(key)) + } @Test - fun documentWithoutDistinctThumbDoesNotHitNetwork() = runBlocking { - var fetches = 0 - val repo = MediaRepository( - cacheRoot = tmp.newFolder("doc-thumb"), - telegramFetcher = TelegramMediaFetcher { _, _, destPath, _, _ -> - fetches += 1 - File(destPath).writeBytes(byteArrayOf(1)) - Outcome.Ok(destPath) - }, - ) - val message = Message( - id = MessageId(PeerId(1), 9), - senderId = null, - text = "file.pdf", - date = 0L, - outgoing = false, - mediaKind = "document", - mediaCacheKey = "doc:5285428961101259905", - ) - val result = repo.ensureLocalMessageThumb(message) - assertTrue(result is Outcome.Err) - assertEquals("no downloadable thumb", (result as Outcome.Err).message) - assertEquals(0, fetches) - } + fun ensureLocalMessageThumbAlwaysRequestsThumb() = + runBlocking { + var lastThumb: Boolean? = null + val repo = + MediaRepository( + cacheRoot = tmp.newFolder("cache"), + telegramFetcher = + TelegramMediaFetcher { _, _, destPath, kind, _ -> + lastThumb = kind == MediaFetchKind.Thumb + File(destPath).writeBytes(byteArrayOf(7, 7, 7)) + Outcome.Ok(destPath) + }, + ) + val message = + Message( + id = MessageId(PeerId(1), 9), + senderId = null, + text = null, + date = 0L, + outgoing = false, + mediaCacheKey = "doc:1:thumb", + thumbCacheKey = "doc:1:thumb", + ) + val result = repo.ensureLocalMessageThumb(message) + assertTrue(result is Outcome.Ok) + assertEquals(true, lastThumb) + } @Test - fun ensureLocalMessageDisplayRequestsDisplayKind() = runBlocking { - var lastKind: MediaFetchKind? = null - var lastPriority: Int? = null - val repo = MediaRepository( - cacheRoot = tmp.newFolder("cache-display"), - telegramFetcher = TelegramMediaFetcher { _, _, destPath, kind, priority -> - lastKind = kind - lastPriority = priority - File(destPath).writeBytes(byteArrayOf(8, 8, 8)) - Outcome.Ok(destPath) - }, - ) - val message = Message( - id = MessageId(PeerId(1), 9), - senderId = null, - text = null, - date = 0L, - outgoing = false, - mediaKind = "photo", - mediaCacheKey = "photo:9", - thumbCacheKey = "photo:9:thumb", - ) - val result = repo.ensureLocalMessageDisplay(message) - assertTrue(result is Outcome.Ok) - assertEquals(MediaFetchKind.Display, lastKind) - assertEquals(MediaPriority.DISPLAY, lastPriority) - assertEquals("photo:9:display", photoDisplayCacheKey("photo:9")) - } + fun documentWithoutDistinctThumbDoesNotHitNetwork() = + runBlocking { + var fetches = 0 + val repo = + MediaRepository( + cacheRoot = tmp.newFolder("doc-thumb"), + telegramFetcher = + TelegramMediaFetcher { _, _, destPath, _, _ -> + fetches += 1 + File(destPath).writeBytes(byteArrayOf(1)) + Outcome.Ok(destPath) + }, + ) + val message = + Message( + id = MessageId(PeerId(1), 9), + senderId = null, + text = "file.pdf", + date = 0L, + outgoing = false, + mediaKind = "document", + mediaCacheKey = "doc:5285428961101259905", + ) + val result = repo.ensureLocalMessageThumb(message) + assertTrue(result is Outcome.Err) + assertEquals("no downloadable thumb", (result as Outcome.Err).message) + assertEquals(0, fetches) + } + + @Test + fun ensureLocalMessageDisplayRequestsDisplayKind() = + runBlocking { + var lastKind: MediaFetchKind? = null + var lastPriority: Int? = null + val repo = + MediaRepository( + cacheRoot = tmp.newFolder("cache-display"), + telegramFetcher = + TelegramMediaFetcher { _, _, destPath, kind, priority -> + lastKind = kind + lastPriority = priority + File(destPath).writeBytes(byteArrayOf(8, 8, 8)) + Outcome.Ok(destPath) + }, + ) + val message = + Message( + id = MessageId(PeerId(1), 9), + senderId = null, + text = null, + date = 0L, + outgoing = false, + mediaKind = "photo", + mediaCacheKey = "photo:9", + thumbCacheKey = "photo:9:thumb", + ) + val result = repo.ensureLocalMessageDisplay(message) + assertTrue(result is Outcome.Ok) + assertEquals(MediaFetchKind.Display, lastKind) + assertEquals(MediaPriority.DISPLAY, lastPriority) + assertEquals("photo:9:display", photoDisplayCacheKey("photo:9")) + } @Test - fun cachedMediaDoesNotFetchAdditionalData() = runBlocking { - val root = tmp.newFolder("consolidated-cache-hit") - val primed = MediaRepository( - cacheRoot = root, - telegramFetcher = TelegramMediaFetcher { _, _, destPath, _, _ -> - File(destPath).writeBytes(byteArrayOf(1, 2, 3, 4)) - Outcome.Ok(destPath) - }, - ) - var networkFetches = 0 - val failingRepo = MediaRepository( - cacheRoot = root, - telegramFetcher = TelegramMediaFetcher { _, _, _, _, _ -> - networkFetches++ - Outcome.Err("network") - }, - ) + fun cachedMediaDoesNotFetchAdditionalData() = + runBlocking { + val root = tmp.newFolder("consolidated-cache-hit") + val primed = + MediaRepository( + cacheRoot = root, + telegramFetcher = + TelegramMediaFetcher { _, _, destPath, _, _ -> + File(destPath).writeBytes(byteArrayOf(1, 2, 3, 4)) + Outcome.Ok(destPath) + }, + ) + var networkFetches = 0 + val failingRepo = + MediaRepository( + cacheRoot = root, + telegramFetcher = + TelegramMediaFetcher { _, _, _, _, _ -> + networkFetches++ + Outcome.Err("network") + }, + ) - val thumbMessage = Message( - id = MessageId(PeerId(4), 2), - senderId = null, - text = null, - date = 0L, - outgoing = false, - mediaCacheKey = "doc:4:thumb", - thumbCacheKey = "doc:4:thumb", - ) - val fullMediaMessage = Message( - id = MessageId(PeerId(6), 3), - senderId = null, - text = "notes.pdf", - date = 0L, - outgoing = false, - mediaKind = "document", - mediaCacheKey = "doc:6", - fileSize = 4L, - ) - val avatarPeer = PeerId(11) - val avatarKey = "avatar:11" + val thumbMessage = + Message( + id = MessageId(PeerId(4), 2), + senderId = null, + text = null, + date = 0L, + outgoing = false, + mediaCacheKey = "doc:4:thumb", + thumbCacheKey = "doc:4:thumb", + ) + val fullMediaMessage = + Message( + id = MessageId(PeerId(6), 3), + senderId = null, + text = "notes.pdf", + date = 0L, + outgoing = false, + mediaKind = "document", + mediaCacheKey = "doc:6", + fileSize = 4L, + ) + val avatarPeer = PeerId(11) + val avatarKey = "avatar:11" - assertTrue(primed.ensureLocalMessageThumb(thumbMessage) is Outcome.Ok) - assertTrue(primed.ensureLocalMessageMedia(fullMediaMessage) is Outcome.Ok) - assertTrue(primed.ensureLocalAvatar(avatarPeer, avatarKey) is Outcome.Ok) - withTimeout(1_000) { - while (primed.cachedFile(avatarKey) == null) delay(10) - } + assertTrue(primed.ensureLocalMessageThumb(thumbMessage) is Outcome.Ok) + assertTrue(primed.ensureLocalMessageMedia(fullMediaMessage) is Outcome.Ok) + assertTrue(primed.ensureLocalAvatar(avatarPeer, avatarKey) is Outcome.Ok) + withTimeout(1_000) { + while (primed.cachedFile(avatarKey) == null) delay(10) + } - assertTrue(failingRepo.ensureLocalMessageThumb(thumbMessage) is Outcome.Ok) - assertTrue(failingRepo.ensureLocalMessageMedia(fullMediaMessage) is Outcome.Ok) - assertTrue(failingRepo.ensureLocalAvatar(avatarPeer, avatarKey) is Outcome.Ok) + assertTrue(failingRepo.ensureLocalMessageThumb(thumbMessage) is Outcome.Ok) + assertTrue(failingRepo.ensureLocalMessageMedia(fullMediaMessage) is Outcome.Ok) + assertTrue(failingRepo.ensureLocalAvatar(avatarPeer, avatarKey) is Outcome.Ok) - assertEquals(0, networkFetches) - assertEquals(4L, failingRepo.cachedFile("doc:6")!!.length()) - assertTrue( - failingRepo.cachedFile("avatar:11:small") != null || + assertEquals(0, networkFetches) + assertEquals(4L, failingRepo.cachedFile("doc:6")!!.length()) + assertTrue( + failingRepo.cachedFile("avatar:11:small") != null || failingRepo.cachedAvatar(avatarPeer) != null, - ) - } + ) + } @Test - fun videoAvatarKeyDoesNotReuseStillJpeg() = runBlocking { - val root = tmp.newFolder("avatar-video") - val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0x00) - val mp4 = byteArrayOf(0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 1, 2, 3, 4) - val primed = MediaRepository( - cacheRoot = root, - telegramFetcher = TelegramMediaFetcher { _, _, destPath, _, _ -> - File(destPath).writeBytes(jpeg) - Outcome.Ok(destPath) - }, - ) - assertTrue(primed.ensureLocalAvatar(PeerId(11), "avatar:11") is Outcome.Ok) - var fetches = 0 - val repo = MediaRepository( - cacheRoot = root, - telegramFetcher = TelegramMediaFetcher { _, _, destPath, _, _ -> - fetches++ - File(destPath).writeBytes(mp4) - Outcome.Ok(destPath) - }, - ) - val video = repo.ensureLocalAvatar(PeerId(11), "avatar:11:video") - assertTrue(video is Outcome.Ok) - assertEquals(1, fetches) - assertEquals(mp4.toList(), (video as Outcome.Ok).value.readBytes().toList()) - } + fun newAvatarKeyDownloadsWhileOldAliasIsLeased() = + runBlocking { + val peer = PeerId(42) + val newBytes = byteArrayOf(9, 8, 7) + val repo = + MediaRepository( + cacheRoot = tmp.newFolder("leased-avatar-refresh"), + telegramFetcher = + TelegramMediaFetcher { _, _, destPath, _, _ -> + File(destPath).writeBytes(newBytes) + Outcome.Ok(destPath) + }, + ) + val oldBytes = byteArrayOf(1, 2, 3) + repo.cacheThumbnail("avatar:42", oldBytes, peer.value, "photo") + val lease = checkNotNull(repo.acquireCachedFile("avatar:42")) + try { + assertTrue(repo.ensureLocalAvatar(peer, "photo:new") is Outcome.Ok) + withTimeout(5_000) { + while (repo.cachedFile("photo:new") == null) delay(10) + } + assertEquals( + newBytes.toList(), + checkNotNull(repo.cachedFile("photo:new")).readBytes().toList() + ) + assertEquals(oldBytes.toList(), lease.file.readBytes().toList()) + } finally { + lease.close() + repo.shutdown() + } + } @Test - fun avatarDownloadBumpsCacheGeneration() = runBlocking { - val repo = MediaRepository( - cacheRoot = tmp.newFolder("gen"), - telegramFetcher = TelegramMediaFetcher { _, _, destPath, _, _ -> - File(destPath).writeBytes(byteArrayOf(3, 3, 3)) - Outcome.Ok(destPath) - }, - ) - assertEquals(0L, repo.cacheGeneration.value) - assertTrue(repo.ensureLocalAvatar(PeerId(5), "avatar:5") is Outcome.Ok) - assertTrue(repo.cacheGeneration.value > 0L) - } + fun videoAvatarKeyDoesNotReuseStillJpeg() = + runBlocking { + val root = tmp.newFolder("avatar-video") + val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0x00) + val mp4 = byteArrayOf(0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 1, 2, 3, 4) + val primed = + MediaRepository( + cacheRoot = root, + telegramFetcher = + TelegramMediaFetcher { _, _, destPath, _, _ -> + File(destPath).writeBytes(jpeg) + Outcome.Ok(destPath) + }, + ) + assertTrue(primed.ensureLocalAvatar(PeerId(11), "avatar:11") is Outcome.Ok) + var fetches = 0 + val repo = + MediaRepository( + cacheRoot = root, + telegramFetcher = + TelegramMediaFetcher { _, _, destPath, _, _ -> + fetches++ + File(destPath).writeBytes(mp4) + Outcome.Ok(destPath) + }, + ) + val video = repo.ensureLocalAvatar(PeerId(11), "avatar:11:video") + assertTrue(video is Outcome.Ok) + assertEquals(1, fetches) + assertEquals(mp4.toList(), (video as Outcome.Ok).value.readBytes().toList()) + } @Test - fun keyedCacheGenerationOnlyEmitsForObservedKey() = runBlocking { - val repo = MediaRepository( - cacheRoot = tmp.newFolder("keyed-gen"), - telegramFetcher = TelegramMediaFetcher { _, _, destPath, _, _ -> - File(destPath).writeBytes(byteArrayOf(3, 3, 3)) - Outcome.Ok(destPath) - }, - ) - val observed = CompletableDeferred() - val observer = launch(start = CoroutineStart.UNDISPATCHED) { - repo.cacheGeneration("avatar:5").drop(1).collect { observed.complete(it) } + fun avatarDownloadBumpsCacheGeneration() = + runBlocking { + val repo = + MediaRepository( + cacheRoot = tmp.newFolder("gen"), + telegramFetcher = + TelegramMediaFetcher { _, _, destPath, _, _ -> + File(destPath).writeBytes(byteArrayOf(3, 3, 3)) + Outcome.Ok(destPath) + }, + ) + assertEquals(0L, repo.cacheGeneration.value) + assertTrue(repo.ensureLocalAvatar(PeerId(5), "avatar:5") is Outcome.Ok) + assertTrue(repo.cacheGeneration.value > 0L) } - assertTrue(repo.ensureLocalAvatar(PeerId(6), "avatar:6") is Outcome.Ok) - assertFalse(observed.isCompleted) + @Test + fun keyedCacheGenerationOnlyEmitsForObservedKey() = + runBlocking { + val repo = + MediaRepository( + cacheRoot = tmp.newFolder("keyed-gen"), + telegramFetcher = + TelegramMediaFetcher { _, _, destPath, _, _ -> + File(destPath).writeBytes(byteArrayOf(3, 3, 3)) + Outcome.Ok(destPath) + }, + ) + val observed = CompletableDeferred() + val observer = + launch(start = CoroutineStart.UNDISPATCHED) { + repo.cacheGeneration("avatar:5").drop(1).collect { observed.complete(it) } + } - assertTrue(repo.ensureLocalAvatar(PeerId(5), "avatar:5") is Outcome.Ok) - assertTrue(withTimeout(1_000) { observed.await() } > 0L) - observer.cancelAndJoin() - repo.shutdown() - } + assertTrue(repo.ensureLocalAvatar(PeerId(6), "avatar:6") is Outcome.Ok) + assertFalse(observed.isCompleted) + + assertTrue(repo.ensureLocalAvatar(PeerId(5), "avatar:5") is Outcome.Ok) + assertTrue(withTimeout(1_000) { observed.await() } > 0L) + observer.cancelAndJoin() + repo.shutdown() + } + + @Test + fun keyedCacheGenerationObservesChangeBeforeInitialCollectorReturns() = + runBlocking { + val repo = MediaRepository(cacheRoot = tmp.newFolder("keyed-registration")) + val initial = CompletableDeferred() + val release = CompletableDeferred() + val changed = CompletableDeferred() + val observer = + launch { + var first = true + repo.cacheGeneration("photo:observed").collect { version -> + if (first) { + first = false + initial.complete(Unit) + release.await() + } else { + changed.complete(version) + } + } + } + withTimeout(1_000) { initial.await() } + assertTrue(repo.cacheThumbnail("photo:observed", byteArrayOf(1), 1L, "photo") != null) + release.complete(Unit) + assertTrue(withTimeout(1_000) { changed.await() } > 0L) + observer.cancelAndJoin() + repo.shutdown() + } + + @Test + fun keyedCacheGenerationTracksMultipleKeysAndClearForRemainingCollectors() = + runBlocking { + val repo = MediaRepository(cacheRoot = tmp.newFolder("keyed-multiple")) + val first = Channel(Channel.UNLIMITED) + val second = Channel(Channel.UNLIMITED) + val observer1 = launch { + repo.cacheGeneration(listOf("first", "second")).collect { first.send(it) } + } + val observer2 = launch { repo.cacheGeneration("second").collect { second.send(it) } } + withTimeout(1_000) { + first.receive() + second.receive() + } + observer1.cancelAndJoin() + assertTrue(repo.cacheThumbnail("second", byteArrayOf(1), 1L, "photo") != null) + assertTrue(withTimeout(1_000) { second.receive() } > 0L) + repo.clearCache() + assertTrue(withTimeout(1_000) { second.receive() } > 0L) + observer2.cancelAndJoin() + repo.shutdown() + } + + @Test + fun replacingAvatarAliasInvalidatesObservers() = + runBlocking { + val repo = MediaRepository(cacheRoot = tmp.newFolder("avatar-alias-update")) + val peer = PeerId(8) + assertTrue(repo.cacheThumbnail("photo:old", byteArrayOf(1), 8L, "photo") != null) + assertTrue(repo.ensureLocalAvatar(peer, "photo:old") is Outcome.Ok) + val versions = Channel(Channel.UNLIMITED) + val observer = launch { repo.cacheGeneration("avatar:8").collect { versions.send(it) } } + withTimeout(1_000) { versions.receive() } + assertTrue(repo.cacheThumbnail("photo:new", byteArrayOf(2), 8L, "photo") != null) + assertTrue(repo.ensureLocalAvatar(peer, "photo:new") is Outcome.Ok) + assertTrue(withTimeout(1_000) { versions.receive() } > 0L) + assertEquals( + 2, + repo + .cachedAvatar(peer) + ?.readBytes() + ?.single() + ?.toInt(), + ) + observer.cancelAndJoin() + repo.shutdown() + } @Test fun inlineThumbJpegSkipsFetcher() { var peeks = 0 var fetches = 0 val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()) - val repo = MediaRepository( - cacheRoot = tmp.newFolder("inline-thumb"), - telegramFetcher = TelegramMediaFetcher { _, _, destPath, _, _ -> - fetches += 1 - File(destPath).writeBytes(byteArrayOf(1)) - Outcome.Ok(destPath) - }, - inlineThumbPeek = TelegramInlineThumbPeek { _, _ -> - peeks += 1 - jpeg - }, - ) - val message = Message( - id = MessageId(PeerId(1), 9), - senderId = null, - text = null, - date = 0L, - outgoing = false, - mediaKind = "photo", - mediaCacheKey = "photo:1", - thumbCacheKey = "photo:1:thumb", - ) + val repo = + MediaRepository( + cacheRoot = tmp.newFolder("inline-thumb"), + telegramFetcher = + TelegramMediaFetcher { _, _, destPath, _, _ -> + fetches += 1 + File(destPath).writeBytes(byteArrayOf(1)) + Outcome.Ok(destPath) + }, + inlineThumbPeek = + TelegramInlineThumbPeek { _, _ -> + peeks += 1 + jpeg + }, + ) + val message = + Message( + id = MessageId(PeerId(1), 9), + senderId = null, + text = null, + date = 0L, + outgoing = false, + mediaKind = "photo", + mediaCacheKey = "photo:1", + thumbCacheKey = "photo:1:thumb", + ) assertTrue(repo.inlineThumbJpeg(message).contentEquals(jpeg)) assertEquals(1, peeks) assertEquals(0, fetches) @@ -264,4 +424,31 @@ class MediaRepositoryThumbTest { assertEquals(1, peeks) repo.shutdown() } + + @Test + fun clearCacheDropsInlineThumbnailMemory() { + var backing: ByteArray? = byteArrayOf(1, 2, 3) + val repo = + MediaRepository( + cacheRoot = tmp.newFolder("clear-inline-thumb"), + inlineThumbPeek = TelegramInlineThumbPeek { _, _ -> backing }, + ) + val message = + Message( + id = MessageId(PeerId(1), 9), + senderId = null, + text = null, + date = 0L, + outgoing = false, + mediaKind = "photo", + mediaCacheKey = "photo:clear", + thumbCacheKey = "photo:clear:thumb", + ) + + assertTrue(repo.inlineThumbJpeg(message)!!.contentEquals(byteArrayOf(1, 2, 3))) + backing = null + repo.clearCache() + assertNull(repo.inlineThumbJpeg(message)) + repo.shutdown() + } } diff --git a/vendor/mono-rs b/vendor/mono-rs index 0bc5195bf..ccb2ea460 160000 --- a/vendor/mono-rs +++ b/vendor/mono-rs @@ -1 +1 @@ -Subproject commit 0bc5195bf004d7fec8038626edf680c5edbdb906 +Subproject commit ccb2ea4606e1d90c173a7f1dbe5a66e0fd0a9a72