From 2453cb0e191f23cb7bfcbefc58ca83ed07970ce4 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:02:03 +0300 Subject: [PATCH 1/9] feat: test impl of multi account --- .../monogram/push/ConversationShadeTest.kt | 31 + .../root/AccountDurableUpdateSinkTest.kt | 181 ++++ .../main/java/org/monogram/AccountRegistry.kt | 252 +++++ .../org/monogram/AccountRuntimeManager.kt | 275 ++++++ .../java/org/monogram/AccountWorkManager.kt | 41 + .../main/java/org/monogram/MainActivity.kt | 210 +++-- app/src/main/java/org/monogram/MonogramApp.kt | 858 +++++++++++++++--- app/src/main/java/org/monogram/StartupAuth.kt | 2 + .../main/java/org/monogram/StartupSequence.kt | 31 +- .../org/monogram/push/AccountPushHandler.kt | 14 + .../org/monogram/push/AccountPushRouter.kt | 145 +++ .../push/MonogramFirebaseMessagingService.kt | 10 +- .../push/MonogramUnifiedPushService.kt | 40 +- .../push/NotificationActionReceiver.kt | 30 +- .../org/monogram/push/NotificationChannels.kt | 134 ++- .../monogram/push/NotificationPresenter.kt | 752 +++++++++------ .../java/org/monogram/push/PushCoordinator.kt | 409 ++++++--- .../org/monogram/push/SharedPushSecret.kt | 27 + .../monogram/root/AccountDurableUpdateSink.kt | 130 +++ .../java/org/monogram/root/CloudDraftSync.kt | 30 +- .../org/monogram/root/OutgoingTextSync.kt | 13 +- .../java/org/monogram/root/RootComponent.kt | 594 ++++++------ .../monogram/sponsor/SponsorSyncManager.kt | 71 +- .../java/org/monogram/AccountRegistryTest.kt | 389 ++++++++ .../java/org/monogram/AccountRemovalTest.kt | 97 ++ .../org/monogram/AccountWorkManagerTest.kt | 83 ++ .../test/java/org/monogram/StartupAuthTest.kt | 45 + .../java/org/monogram/StartupSequenceTest.kt | 95 ++ .../monogram/push/AccountPushRouterTest.kt | 88 ++ .../push/NotificationActionOwnerTest.kt | 39 + .../org/monogram/root/TabletNavigationTest.kt | 6 + .../common/push/NotificationLocalStore.kt | 321 ++++--- .../core/common/push/NotificationPolicy.kt | 24 +- .../push/NotificationPopupPolicyTest.kt | 17 + .../database/AccountCacheIsolationTest.kt | 177 ++++ .../core/database/HistoryInvalidationTest.kt | 127 +++ .../core/database/SessionMetadataStoreTest.kt | 62 ++ .../core/database/DatabaseProvider.kt | 165 ++-- .../monogram/core/database/OfflineWarmup.kt | 30 +- .../core/database/SessionMetadataStore.kt | 86 +- .../monogram/core/database/dao/MessageDao.kt | 8 +- .../org/monogram/core/database/dao/MetaDao.kt | 3 + .../core/database/OfflineWarmupCleanupTest.kt | 8 + .../monogram/core/ui/AccountImageCacheTest.kt | 57 ++ .../java/org/monogram/core/ui/ImageCache.kt | 81 +- .../core/ui/components/SettingsTile.kt | 36 +- .../monogram/feature/chats/AccountMenuItem.kt | 9 + .../monogram/feature/chats/ChatsComponent.kt | 5 + .../feature/chats/ui/ChatListAppBar.kt | 52 ++ .../monogram/feature/chats/ui/ChatsContent.kt | 31 +- .../chats/src/main/res/values-es/strings.xml | 5 + .../chats/src/main/res/values-hy/strings.xml | 5 + .../chats/src/main/res/values-ja/strings.xml | 5 + .../src/main/res/values-pt-rBR/strings.xml | 5 + .../chats/src/main/res/values-ru/strings.xml | 5 + .../chats/src/main/res/values-sk/strings.xml | 5 + .../chats/src/main/res/values-tr/strings.xml | 5 + .../chats/src/main/res/values-uk/strings.xml | 5 + .../chats/src/main/res/values-zh/strings.xml | 5 + feature/chats/src/main/res/values/strings.xml | 5 + .../feature/dialog/store/DialogExecutor.kt | 28 +- .../feature/dialog/ui/ComposerMediaPanels.kt | 34 +- .../feature/dialog/DialogForumStoreTest.kt | 54 +- .../feature/settings/SettingsComponent.kt | 47 + .../feature/settings/SettingsStore.kt | 52 +- .../feature/settings/ui/SettingsContent.kt | 56 +- .../feature/settings/ui/SettingsHome.kt | 182 +++- .../src/main/res/values-es/strings.xml | 4 + .../src/main/res/values-hy/strings.xml | 4 + .../src/main/res/values-ja/strings.xml | 4 + .../src/main/res/values-pt-rBR/strings.xml | 4 + .../src/main/res/values-ru/strings.xml | 4 + .../src/main/res/values-sk/strings.xml | 4 + .../src/main/res/values-tr/strings.xml | 4 + .../src/main/res/values-uk/strings.xml | 4 + .../src/main/res/values-zh/strings.xml | 4 + .../settings/src/main/res/values/strings.xml | 4 + gradle/wrapper/gradle-wrapper.properties | 4 +- .../jniLibs/arm64-v8a/libmonogram_markup.so | Bin 990440 -> 989328 bytes .../jniLibs/armeabi-v7a/libmonogram_markup.so | Bin 805316 -> 804564 bytes .../main/jniLibs/x86_64/libmonogram_markup.so | Bin 1020600 -> 1018696 bytes .../monogram/mtproto/SessionKeyStoreTest.kt | 170 +++- .../monogram/mtproto/MtprotoNativeLoader.kt | 11 + .../org/monogram/mtproto/SessionKeyStore.kt | 152 +++- .../bridge/session/NativeWorkerRegistry.kt | 119 +++ .../network/bridge/session/SessionCore.kt | 166 ++-- .../network/bridge/updates/UpdatesPump.kt | 115 ++- .../BridgedMtprotoClientLifecycleTest.kt | 1 + .../session/NativeWorkerRegistryTest.kt | 175 ++++ .../session/SessionCoreOwnershipTest.kt | 48 + .../updates/ChannelDifferenceMappingTest.kt | 16 + .../monogram/network/http/MediaRepository.kt | 230 +++-- .../network/http/MediaRepositoryThumbTest.kt | 39 + vendor/mono-rs | 2 +- 94 files changed, 6514 insertions(+), 1698 deletions(-) create mode 100644 app/src/androidTest/java/org/monogram/root/AccountDurableUpdateSinkTest.kt create mode 100644 app/src/main/java/org/monogram/AccountRegistry.kt create mode 100644 app/src/main/java/org/monogram/AccountRuntimeManager.kt create mode 100644 app/src/main/java/org/monogram/AccountWorkManager.kt create mode 100644 app/src/main/java/org/monogram/push/AccountPushHandler.kt create mode 100644 app/src/main/java/org/monogram/push/AccountPushRouter.kt create mode 100644 app/src/main/java/org/monogram/push/SharedPushSecret.kt create mode 100644 app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt create mode 100644 app/src/test/java/org/monogram/AccountRegistryTest.kt create mode 100644 app/src/test/java/org/monogram/AccountRemovalTest.kt create mode 100644 app/src/test/java/org/monogram/AccountWorkManagerTest.kt create mode 100644 app/src/test/java/org/monogram/StartupAuthTest.kt create mode 100644 app/src/test/java/org/monogram/StartupSequenceTest.kt create mode 100644 app/src/test/java/org/monogram/push/AccountPushRouterTest.kt create mode 100644 app/src/test/java/org/monogram/push/NotificationActionOwnerTest.kt create mode 100644 core/database/src/androidTest/java/org/monogram/core/database/AccountCacheIsolationTest.kt create mode 100644 core/database/src/androidTest/java/org/monogram/core/database/HistoryInvalidationTest.kt create mode 100644 core/database/src/androidTest/java/org/monogram/core/database/SessionMetadataStoreTest.kt create mode 100644 core/ui/src/androidTest/java/org/monogram/core/ui/AccountImageCacheTest.kt create mode 100644 feature/chats/src/main/java/org/monogram/feature/chats/AccountMenuItem.kt create mode 100644 network/bridge/src/main/java/org/monogram/network/bridge/session/NativeWorkerRegistry.kt create mode 100644 network/bridge/src/test/java/org/monogram/network/bridge/session/NativeWorkerRegistryTest.kt create mode 100644 network/bridge/src/test/java/org/monogram/network/bridge/updates/ChannelDifferenceMappingTest.kt diff --git a/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt b/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt index 4bdb73cf6..39424a853 100644 --- a/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt +++ b/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt @@ -47,6 +47,37 @@ 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 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..8555f72b8 --- /dev/null +++ b/app/src/main/java/org/monogram/AccountRegistry.kt @@ -0,0 +1,252 @@ +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 (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..05f0698e2 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 @@ -67,15 +71,14 @@ open class MainActivity : ComponentActivity() { /** 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 +87,7 @@ open class MainActivity : ComponentActivity() { if (!supported) return runCatching { setPictureInPictureParams( - PictureInPictureParams.Builder() - .setAspectRatio(Rational(16, 9)) - .build(), + PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).build(), ) } } @@ -131,75 +132,155 @@ 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 (intent.action == NotificationPresenter.ACTION_OPEN_CHAT || intent.action == NotificationPresenter.ACTION_OPEN_NOTIFICATIONS) { + 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, + 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 -> + app.switchAccount(userId) + recreate() + }, + onRemoveAccount = { userId -> + app.removeAccount(userId) + recreate() + }, ) if (pendingIncomingShare != null) { root.openIncomingShare(pendingIncomingShare!!) } else if (!shareIntentHandled) { 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(), + ) + } } } } @@ -240,15 +321,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 +353,25 @@ open class MainActivity : ComponentActivity() { } return } + if (intent.action == NotificationPresenter.ACTION_OPEN_CHAT || intent.action == NotificationPresenter.ACTION_OPEN_NOTIFICATIONS) { + 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 -> return + NotificationAccountRoute.Open -> Unit + is NotificationAccountRoute.Switch -> { + lifecycleScope.launch { + (application as MonogramApp).switchAccount(route.userId) + recreate() + } + return + } + } + } 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) diff --git a/app/src/main/java/org/monogram/MonogramApp.kt b/app/src/main/java/org/monogram/MonogramApp.kt index 5fba7a656..d873f5df3 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,9 @@ 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.PeerId import org.monogram.core.ui.AppUpdateSettings import org.monogram.core.ui.AppearanceSettings @@ -39,11 +52,14 @@ 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.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 +69,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 +99,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 +112,122 @@ 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 { org.monogram.core.models.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 +247,364 @@ 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, + ) + 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 { + 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 -> + 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 title = + runtime.sessionStore.readProfile(account.userId)?.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, + ) + } 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 +614,88 @@ 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 = TelegramChunkFetcher { chatId, messageId, path, offset -> + client.downloadMessageMediaChunk(chatId, messageId, path, 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, 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) @@ -188,30 +738,48 @@ class MonogramApp : Application() { PerfLog.mark("app:ready", PerfLog.nowMs() - startedAt) settingsScope.launch { steps.mediaMigration() } startAfterFirstPaint(steps) + 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 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( + db, outgoingMediaFiles = OutgoingMediaFiles( File(noBackupFilesDir, "outgoing-media") ) ) @@ -225,23 +793,35 @@ 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 = @@ -273,44 +853,53 @@ class MonogramApp : Application() { perfSpan("app:imageCache") { ImageCache.install(this) } }, cleanup = { + 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, + ) + } + } runCatching { warmup.prepareStartupSnapshot(sessionStore) }.onFailure { AppLog.warn( - "warmup", - "startup cleanup failed" + "warmup", "startup cleanup failed" ) } }, 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" + "app:prewarm", PerfLog.nowMs() - started, "result=ok" ) is Outcome.Err -> PerfLog.mark( - "app:prewarm", - PerfLog.nowMs() - started, - "result=err" + "app:prewarm", PerfLog.nowMs() - started, "result=err" ) null -> PerfLog.mark( - "app:prewarm", - PerfLog.nowMs() - started, - "result=throw" + "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 + durableMedia, overwrite = false ) }.onFailure { AppLog.warn("media", "cache migration failed") } } @@ -323,65 +912,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 +950,19 @@ class MonogramApp : Application() { mediaRepository, DefaultStoreFactory(), sessionStore, + otherAccountUserIds = { + accountRegistry.all().map { it.userId } + }, + sharedPushSecret = sharedPushSecret::get, ) } + pushRouter = AccountPushRouter() + currentAccount()?.userId?.let { userId -> + push.setAccountUserId(userId) + pushRouter.register(userId, push) + accountPushes[userId] = push + pushRouterCoordinatorIds += userId + } sponsorSync = SponsorSyncManager( settingsScope, client, @@ -401,14 +972,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 = { @@ -443,24 +1013,67 @@ class MonogramApp : Application() { startPush = { runCatching { push.start() }.onFailure { AppLog.warn( - "startup", - "push start failed" + "startup", "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" + "startup", "sponsor start failed" ) } }, startAppUpdate = { runCatching { appUpdate.start() }.onFailure { AppLog.warn( - "startup", - "app update start failed" + "startup", "app update start failed" ) } }, @@ -482,6 +1095,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() @@ -499,16 +1121,12 @@ private fun isEmulatorDevice(model: String): Boolean { val product = Build.PRODUCT.orEmpty() val hardware = Build.HARDWARE.orEmpty() return fingerprint.startsWith("generic") || fingerprint.contains( - "emulator", - ignoreCase = true + "emulator", ignoreCase = true ) || product.contains("sdk", ignoreCase = true) || hardware.contains( - "goldfish", - ignoreCase = true + "goldfish", ignoreCase = true ) || hardware.contains("ranchu", ignoreCase = true) || model.contains( - "sdk_gphone", - ignoreCase = true + "sdk_gphone", ignoreCase = true ) || model.contains("emulator", ignoreCase = true) || model.contains( - "google_sdk", - ignoreCase = true + "google_sdk", 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..4c8b0592e 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 @@ -17,6 +18,7 @@ internal data class StartupSteps( val createMedia: suspend () -> Unit, val createPush: suspend () -> Unit, val startPush: () -> Unit = {}, + val startAccounts: suspend () -> Unit = {}, val startSponsor: () -> Unit = {}, val startAppUpdate: () -> Unit = {}, ) @@ -28,11 +30,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 +43,27 @@ internal suspend fun runFirstPaintStartup( } } -internal fun startAfterFirstPaint(steps: StartupSteps) { +internal suspend fun startAfterFirstPaint(steps: StartupSteps) { 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..43c85d6a3 100644 --- a/app/src/main/java/org/monogram/push/NotificationPresenter.kt +++ b/app/src/main/java/org/monogram/push/NotificationPresenter.kt @@ -63,11 +63,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,7 +92,7 @@ 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>() @@ -105,20 +108,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 +134,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 +150,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 +170,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 +186,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 +218,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 +229,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 +266,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 +295,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 +314,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 +370,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 +460,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 +486,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 +503,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 +612,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 +627,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 +653,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 +699,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 +728,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 +749,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 +790,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 +818,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 +844,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 +905,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 +922,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 +933,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 +949,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 +971,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 +993,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 +1012,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 +1055,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 +1080,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 +1092,8 @@ object NotificationPresenter { remaining, silent = true, onlyAlertOnce = true, - badge = badge + badge = badge, + accountUserId = accountUserId, ) } } @@ -935,18 +1105,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 +1131,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 +1145,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 +1179,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 +1188,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 +1201,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 +1249,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 +1264,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 +1278,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 +1299,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/PushCoordinator.kt b/app/src/main/java/org/monogram/push/PushCoordinator.kt index 3170ec86e..18caf974f 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,7 @@ 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.SessionMetadataStore import org.monogram.core.models.Chat import org.monogram.core.models.Folder import org.monogram.core.models.Message @@ -73,22 +78,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 +103,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 +118,12 @@ 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, +) : PushRegistration, AccountPushHandler { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + @Volatile var openChatId: Long? = null @@ -128,18 +136,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 +162,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 +186,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 +239,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 +249,7 @@ class PushCoordinator( emptySet(), update.maxId, store.badgeSettings(), + accountUserId = accountUserId, ) is MtprotoUpdate.ReadHistoryConfirmed -> NotificationPresenter.dropMessages( @@ -230,18 +258,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,6 +289,8 @@ class PushCoordinator( } } + internal fun usesClient(candidate: MtprotoClient): Boolean = client === candidate + override fun setAccountUserId(userId: Long) { accountUserId = userId } @@ -266,13 +299,13 @@ class PushCoordinator( 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 +313,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 +323,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 +347,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 +365,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 +389,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 +406,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 +423,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 +505,7 @@ class PushCoordinator( runCatching { val register = { UnifiedPush.register( - context, - instance = instance, - messageForDistributor = "Monogram" + context, instance = instance, messageForDistributor = "Monogram" ) } if (chosen.isNotBlank()) { @@ -486,12 +540,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 +586,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 +601,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 +626,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 +640,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() - } + 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, - ) - - 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() { @@ -700,7 +784,8 @@ class PushCoordinator( chatId, ids.toSet(), upTo = null, - badge = store.badgeSettings() + badge = store.badgeSettings(), + accountUserId = accountUserId, ) } maybeWake() @@ -714,6 +799,7 @@ class PushCoordinator( dropIds = emptySet(), upTo = historyReadUpTo(payload.messageId ?: 0, payload.maxId ?: 0), badge = store.badgeSettings(), + accountUserId = accountUserId, ) } maybeWake() @@ -728,8 +814,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 +830,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), @@ -776,9 +860,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 +878,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 +914,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 +931,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 +942,8 @@ class PushCoordinator( } } } - } finally { - done() } - } + }.invokeOnCompletion { done() } } private fun rememberChatPhotos(chats: List) { @@ -877,7 +969,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,11 +990,13 @@ 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, + accountUserId = accountUserId, ) timing.mark("image_post_requested") completed = true @@ -926,8 +1023,10 @@ class PushCoordinator( file, mode, badge, - quiet = true + quiet = true, + accountUserId = accountUserId, ) + return@launch } val key = chatPhotos[chatId] ?: peerAvatarCacheKey(PeerId(chatId)) @@ -943,8 +1042,10 @@ class PushCoordinator( file, mode, badge, - quiet = true + quiet = true, + accountUserId = accountUserId, ) + } } @@ -966,30 +1067,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 +1117,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 +1162,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 +1181,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 +1218,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..b1f908f74 --- /dev/null +++ b/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt @@ -0,0 +1,130 @@ +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 + +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 = 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 { update -> + if (update is MtprotoUpdate.OutgoingMessageAcknowledged && update.randomId in mediaIds) return@forEach + 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..a1e45ad0a 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() diff --git a/app/src/main/java/org/monogram/root/RootComponent.kt b/app/src/main/java/org/monogram/root/RootComponent.kt index 1f41f4343..a1a6904a7 100644 --- a/app/src/main/java/org/monogram/root/RootComponent.kt +++ b/app/src/main/java/org/monogram/root/RootComponent.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.monogram.BuildConfig +import org.monogram.MonogramApp import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome import org.monogram.core.common.PerfLog @@ -34,9 +35,7 @@ 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,6 +46,7 @@ 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 @@ -54,6 +54,7 @@ import org.monogram.feature.dialog.scheduled.ScheduledQueueComponent 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.ui.ProxySession import org.monogram.network.bridge.BridgedMtprotoClient @@ -79,6 +80,21 @@ 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 () -> MonogramApp.PendingAccountAuthorization = { error("Add account unavailable") }, + private val onAdditionalAccountAuthorized: suspend (AuthSession, MonogramApp.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) @@ -112,18 +128,19 @@ class RootComponent( targetWarmup.onFirstFrame() } + private var pendingAdditionalAuthorization: MonogramApp.PendingAccountAuthorization? = null + + 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) @@ -260,8 +277,11 @@ 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, @@ -299,112 +319,25 @@ class RootComponent( } } - 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 } - } - } - 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 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, - ) + private val durableUpdateSink = warmup?.let(::AccountDurableUpdateSink) - 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 - ) - - else -> Unit - } + private suspend fun commitDurableBatch(batch: DurableUpdateBatch): Boolean { + if (expiringSession) return false + return durableUpdateSink?.commit(batch) ?: false } init { + 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,10 +347,7 @@ 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 { @@ -445,10 +375,7 @@ 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) @@ -505,19 +432,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,12 +479,19 @@ 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) } } @@ -573,12 +502,8 @@ class RootComponent( private suspend fun expireSession() { if (expiringSession) return expiringSession = true - draftSyncAuthorized = false - warmup?.deactivateOutgoingAccount() - draftSync?.stop() - outgoingTextSync?.stop() + onAccountWorkStop() stopLiveLocations() - accountFlagsVersion++ accountState.isPremium = false // Destroy account screens and their collectors before clearing their cache. MediaPlaybackHolder.peek()?.stop() @@ -587,208 +512,243 @@ 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 -> + Config.Auth -> Child.Auth( + AuthComponent(context, storeFactory, client) { session -> expiringSession = false scope.launch { + onAccountAuthorized(session) sessionStore?.saveAuthorized(session) if (expiringSession) return@launch - warmup?.activateOutgoingAccount() - if (expiringSession) { - warmup?.deactivateOutgoingAccount(); 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") } - draftSyncAuthorized = true - draftSync?.start(scope) - outgoingTextSync?.start(scope) + if (expiringSession) return@launch + navigation.replaceAll(Config.Home) + pendingIncomingShare?.let(::openIncomingShare) 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) }, - ), - ) + ) - 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.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) } }, - 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.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 -> { + 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, + ), + ) + } + }, + 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) + } + Child.Dialog(component) } - Child.Dialog(component) - } - is Config.ScheduledQueue -> Child.ScheduledQueue( - ScheduledQueueComponent( - context, - storeFactory, - client, - warmup, - PeerId(config.chatId), - ::onBack, - mediaRepository, - ), - ) + 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.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, - onBeforeLogout = { - expiringSession = true - draftSyncAuthorized = false - warmup?.deactivateOutgoingAccount() - draftSync?.stop() - outgoingTextSync?.stop() - stopLiveLocations() - }, - onLoggedOut = { - expiringSession = true - draftSyncAuthorized = false - scope.launch { - draftSync?.stop() - outgoingTextSync?.stop() + 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 + onAccountWorkStop() 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, - ), - ) - } + }, + 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() object Proxy : Child() class Home(val component: HomeComponent) : Child() - class ScheduledQueue(val component: ScheduledQueueComponent) : - Child() + class ScheduledQueue(val component: ScheduledQueueComponent) : Child() class Dialog(val component: DialogComponent) : Child() class Profile(val component: ProfileComponent) : Child() @@ -806,6 +766,9 @@ class RootComponent( @Serializable data object Auth : Config + @Serializable + data object AddAccount : Config + @Serializable data object Proxy : Config @@ -837,8 +800,17 @@ 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 @@ -884,6 +856,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 +874,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/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/test/java/org/monogram/AccountRegistryTest.kt b/app/src/test/java/org/monogram/AccountRegistryTest.kt new file mode 100644 index 000000000..f9a73cb39 --- /dev/null +++ b/app/src/test/java/org/monogram/AccountRegistryTest.kt @@ -0,0 +1,389 @@ +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 restored = AccountRegistry(root) + assertEquals(listOf(11L), restored.load().map { it.userId }) + assertFalse(root.resolve("accounts.json.bak").exists()) + } 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 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()) + } 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 existingStagedRegistryIsNotOverwrittenByNextWrite() { + 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()) + assertEquals(contents, staged.readText()) + assertEquals(listOf(11L), registry.all().map { it.userId }) + assertEquals(listOf(11L), 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()) + assertEquals(listOf(11L), registry.all().map { it.userId }) + assertEquals(11L, registry.activeUserId) + assertEquals(listOf(11L), 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) + assertEquals("interrupted", staged.readText()) + } 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..44053ab61 --- /dev/null +++ b/app/src/test/java/org/monogram/StartupSequenceTest.kt @@ -0,0 +1,95 @@ +package org.monogram + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +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 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/root/TabletNavigationTest.kt b/app/src/test/java/org/monogram/root/TabletNavigationTest.kt index fb591c977..f531d94fc 100644 --- a/app/src/test/java/org/monogram/root/TabletNavigationTest.kt +++ b/app/src/test/java/org/monogram/root/TabletNavigationTest.kt @@ -4,6 +4,12 @@ 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 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/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/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/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..4bda1d88c 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,8 +19,9 @@ import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext object DatabaseProvider { - @Volatile - private var instance: MonogramDatabase? = null + private const val DEFAULT_INSTANCE = "default" + + private val instances = mutableMapOf() const val SCHEMA_VERSION = 10 @@ -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))" ) } } @@ -254,41 +253,88 @@ 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() + } + } + + fun closeDefault() { + synchronized(this) { + instances.remove(DEFAULT_INSTANCE)?.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) } + 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( + 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, @@ -298,24 +344,23 @@ object DatabaseProvider { MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, - ) - .setJournalMode(androidx.room.RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) - .setQueryExecutor(roomQueryExecutor) - .setTransactionExecutor(roomTransactionExecutor) + ).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 +379,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/OfflineWarmup.kt b/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt index c0b10feb2..81b7240bb 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 @@ -389,7 +389,7 @@ open class OfflineWarmup( 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) ) @@ -410,7 +410,7 @@ open class OfflineWarmup( maxId, ) database.metaDao().upsert( - org.monogram.core.database.entity.MetaEntity( + MetaEntity( "discussion_read:${chatId.value}:$topId", confirmed.toString(), ), @@ -717,7 +717,7 @@ open class OfflineWarmup( holeDao.clearChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) holes.forEach { holeDao.insert(it) } database.metaDao() - .upsert(org.monogram.core.database.entity.MetaEntity(metaKey, "1")) + .upsert(MetaEntity(metaKey, "1")) } mergeMessages(messages) val ids = messages.filter { !it.pending && it.id.id > 0 }.map { it.id.id } @@ -777,6 +777,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) { @@ -982,7 +1002,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 @@ -1511,7 +1531,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:%") 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..7e20a0fc6 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 { @@ -93,7 +114,8 @@ open class SessionMetadataStore( avatarCacheKey = chat.photoCacheKey ?: prior?.avatarCacheKey, 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 +149,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 +193,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 +245,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 +312,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/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..c91f0c28c 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 @@ -30,6 +30,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/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/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/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/SettingsTile.kt b/core/ui/src/main/java/org/monogram/core/ui/components/SettingsTile.kt index 97b8da736..c9f35a921 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 @@ -59,11 +59,13 @@ fun SettingsCard( topStart = SettingsGroupCorner, topEnd = SettingsGroupCorner, ) + ItemPosition.MIDDLE -> RoundedCornerShape(0.dp) ItemPosition.BOTTOM -> RoundedCornerShape( bottomStart = SettingsGroupCorner, bottomEnd = SettingsGroupCorner, ) + ItemPosition.STANDALONE -> RoundedCornerShape(SettingsGroupCorner) } val container by animateColorAsState( @@ -123,6 +125,7 @@ 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. @@ -133,6 +136,7 @@ fun SettingsCardRow( role = Role.Switch, onValueChange = onToggle, ) + onClick != null -> Modifier.clickable(enabled = enabled, onClick = onClick) else -> Modifier } @@ -144,18 +148,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,9 +204,10 @@ 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, ) { SettingsCard( @@ -216,6 +225,7 @@ fun SettingsTile( enabled = enabled, selected = selected, trailingContent = trailingContent, + leadingContent = leadingContent, ) } } 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..6a452a0ec --- /dev/null +++ b/feature/chats/src/main/java/org/monogram/feature/chats/AccountMenuItem.kt @@ -0,0 +1,9 @@ +package org.monogram.feature.chats + +data class AccountMenuItem( + val userId: Long, + val title: String, + val needsSignIn: Boolean = false, + val active: Boolean = false, + val unavailable: Boolean = false, +) \ No newline at end of file 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/ui/ChatListAppBar.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatListAppBar.kt index fa9f5bbf8..ee03ccc5c 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 @@ -17,6 +17,9 @@ import androidx.compose.foundation.layout.size 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.AccountCircle +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.CloudDone import androidx.compose.material.icons.outlined.CloudOff @@ -40,6 +43,7 @@ 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 @@ -49,6 +53,8 @@ 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.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,6 +96,11 @@ 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( @@ -162,6 +173,11 @@ internal fun ChatsTopBar( onOpenSettings = onOpenSettings, onOpenProfile = onOpenProfile, onMarkAllRead = onMarkAllRead, + accountMenuItems = accountMenuItems, + onSwitchAccount = onSwitchAccount, + onAddAccount = onAddAccount, + accountMenuLabel = accountMenuLabel, + addAccountLabel = addAccountLabel, ) }, colors = TopAppBarDefaults.topAppBarColors( @@ -283,6 +299,11 @@ 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 { @@ -316,6 +337,37 @@ private fun AccountAvatar( alignToAnchorEnd = true, ) { AppMenuSurface { + AppMenuGroup { + AppMenuItem( + text = accountMenuLabel, + onClick = {}, + enabled = false, + ) + 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.active && !account.unavailable, + icon = if (account.active) Icons.Outlined.Check else Icons.Outlined.AccountCircle, + onClick = { + menuOpen = false + onSwitchAccount(account.userId) + }, + ) + } + AppMenuItem( + text = addAccountLabel, + icon = Icons.Outlined.Add, + onClick = { + menuOpen = false + onAddAccount() + }, + ) + } AppMenuGroup { AppMenuItem( text = profileLabel, 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..b12795d06 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 @@ -505,6 +505,19 @@ 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 +559,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,10 +592,12 @@ fun ChatsContent( } } } - Box(modifier = Modifier - .weight(1f) - .fillMaxSize()) { - androidx.compose.animation.AnimatedVisibility( + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + ) { + AnimatedVisibility( visible = state.loading && paneEmpty && !folderMoving, modifier = Modifier.fillMaxSize(), enter = fadeIn(animationSpec = tween(180)), 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/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..58e7213e2 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 @@ -62,9 +62,7 @@ private fun presenceLabelChanged( ): Boolean { val now = System.currentTimeMillis() return LastSeen.resolve(previousStatus, previousAt, now) != LastSeen.resolve( - nextStatus, - nextAt, - now + nextStatus, nextAt, now ) } @@ -186,9 +184,8 @@ internal class DialogExecutor( } 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 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, @@ -343,8 +340,7 @@ internal class DialogExecutor( dispatch(Msg.PhotoCacheKey(chat.photoCacheKey)) } val rights = rightsMsg(chat) - val stale = - chat.lastMessageId > 0 && sourceLastMessageId > chat.lastMessageId + val stale = chat.lastMessageId > 0 && sourceLastMessageId > chat.lastMessageId if (!stale && !(muteOverride && !chat.muteOverride)) { rawMuted = chat.muted muteOverride = chat.muteOverride @@ -386,10 +382,7 @@ 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() } } @@ -717,15 +710,11 @@ internal class DialogExecutor( 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 + intent.messageId, intent.firstId, intent.titles ) is DialogStore.Intent.SendLocation -> sendLocation( - intent.latitude, - intent.longitude, - intent.livePeriodSeconds + intent.latitude, intent.longitude, intent.livePeriodSeconds ) is DialogStore.Intent.BotButton -> pressBotButton( @@ -961,8 +950,7 @@ internal class DialogExecutor( dispatch( Msg.HasOlder( historyHasMore( - result.value.size, - HISTORY_FIRST_LIMIT + result.value.size, HISTORY_FIRST_LIMIT ) || cached.size > result.value.size || cachedOlderTail.isNotEmpty(), ), ) 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/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt index fe29502a5..4bf724e7e 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 @@ -3481,7 +3481,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 +3566,7 @@ class DialogForumStoreTest { store.accept( DialogStore.Intent.ReplyTo( Message( - org.monogram.core.models.MessageId( + MessageId( PeerId(5), 42 ), null, "Reply", 1, outgoing = false @@ -4149,6 +4149,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) 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..c02a32d8b 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 @@ -100,12 +100,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") } @@ -340,20 +342,27 @@ 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.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 { @@ -379,16 +388,20 @@ fun SettingsContent( selfPeerId = state.profile?.id?.value, updateState = updateState, updatesEnabled = component.updatesEnabled, - devicesSubtitle = activeDeviceCount.sessions - .count { !it.current && !it.passwordPending } + 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.Proxy -> ProxySettings( - component, - Modifier.padding(innerPadding) + component, Modifier.padding(innerPadding) ) SettingsPage.Data -> SettingsPageList(innerPadding) { @@ -517,8 +530,7 @@ fun SettingsContent( } SettingsPage.Wallpaper -> WallpaperSettings( - component, - Modifier.padding(innerPadding) + component, Modifier.padding(innerPadding) ) SettingsPage.Folders -> folders?.content(innerPadding) @@ -528,6 +540,30 @@ fun SettingsContent( } } + 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 = { 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..caf257d64 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 @@ -1,8 +1,12 @@ package org.monogram.feature.settings.ui import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope @@ -11,10 +15,14 @@ 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.Check import androidx.compose.material.icons.outlined.Devices import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Science @@ -22,7 +30,10 @@ import androidx.compose.material.icons.outlined.Star import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.SystemUpdate import androidx.compose.material.icons.outlined.VpnKey +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -32,20 +43,32 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.SettingsGroupGap 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 @@ -69,6 +92,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( @@ -79,6 +108,91 @@ internal fun LazyListScope.homeItems( 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 + ) + } 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 = when { + accountUserIds.size == 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == accountUserIds.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + }, + onClick = if (active) null else { + { component.switchAccount(userId) } + }, + trailingContent = { + if (userId == activeAccountUserId) { + Icon(Icons.Outlined.Check, contentDescription = null) + } else { + var expanded by remember { mutableStateOf(false) } + Box(Modifier.offset(x = 12.dp)) { + IconButton(onClick = { expanded = true }) { + Icon( + Icons.Outlined.MoreVert, + contentDescription = stringResource(org.monogram.core.ui.R.string.media_more_actions), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu( + expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.settings_remove_account)) }, + onClick = { + expanded = false + onRemoveAccount(userId) + }, + ) + } + } + } + }, + ) + } + } + if (accountUserIds.isNotEmpty()) { + item(key = "settings-account-gap") { Spacer(Modifier.height(SettingsGroupGap)) } + } + item { + SettingsTile( + icon = Icons.Outlined.Add, + title = stringResource(R.string.settings_add_account), + iconColor = MaterialTheme.colorScheme.primary, + position = ItemPosition.STANDALONE, + onClick = onAddAccount, + trailingContent = { Chevron() }, + ) + } item { SectionHeader(stringResource(R.string.settings_section_settings)) } item { SettingsTile( @@ -330,8 +444,7 @@ internal fun LazyListScope.homeItems( @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) + 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 is AppUpdateState.Available -> { @@ -348,8 +461,9 @@ 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 @@ -361,18 +475,21 @@ private fun updateTileCopy(state: AppUpdateState): Pair { ) } - 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) + } } } @@ -396,6 +513,40 @@ 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) } + 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( @@ -467,6 +618,5 @@ private fun displayPhone(raw: String): String { 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 + ?.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..f11acbd9c 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 +#Tue Nov 10 21:37:06 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.8.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists 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 4d929c5efdba4227c91e72ea8e8616b13ff7742c..4d1feae3f2ebd511fe0f7625774d72b7953d7ea8 100644 GIT binary patch delta 343129 zcmce<4}4p7z5o9?$w_5;C1Ta6RfDT6YSo|>BNhl!G-AOZ1%ng}S~a?Q`+J>pKBr6G?A*)l z1YNmeabZajKpiH^c{oi>!&fHa6 zJoZjbw?;i(&a_Y!xjRegsnp`RmZ)6Cm|GJjKq=7m-&#Sg%e92A%R`q5Yu@=r>!Fmd ze0g5z=@7xT|FEd9DW_Ug&8AL#SJ*8GkA?*EIl^U zS)=|uPyJb^{#>vAyhQ!E{lwcvpCJ5DeKDr~oKSy09okg(R^e6k`ftil)2ZsPXS->c zU?q>RZ=UZ)LZ#_052D_YL+EFiau1lbx~>jqg9%(=UxkC>@~CIdr3YY zFW3AS_o0Hc_H8822)a)xD?1BX>R}EJr^LkUC()2deR!YQ-0m}9O1bgC#lT5c{$?0 z&%3XK*V)ax?+4gX&NR-|yj!C@pZW3mKCf1a6cFJ3JxToSyh1a?-^RyTCgc3@nWyXZ zjPV4FBv{Hvqn3=uS{`2{@ptg}b&Eo#>bKH)f1Hn@v>5Hdmp(1m@KNd~&G-ZFrxD`E zc} zw4jC0q!#Y=2C&d)7rrT!Tv!%LFKjqbX1&o|p>*C+d`qFuB^Rx<<7yGUyYPJrzL7s` z!1ob+JMi5d7T#vUjbq6x&7UV@3PRrnss6C zFRJCxP))P2AoRqNCAbF(OaC+M|3$SmoPMNQ3Jor`TNWd7ZTJb>!X@V|!{vqeR=&>H zz%Jrk75eMaEfp8=R~4a6%We}EhQ6_EoveT)tAfYy=##-@0|?ZoGJ{4U~KcziGMdPRGQ z-}oqMkMdcN1XSmhb}<$s{tHS1d?ks0=fj%M82G$_mkQNt`SU~Yll!!c>gS{uvW(*dg2Gx8I!S0q4us#@C=g2bn}j}Sjr z&9}iQ3D7f&5kJl|N)aFJ)LNV)e)u)5VR_>9_yr#ci1XL%AEiT(cs+iGc)cgGidW{pJ_LG3Ig&uH zNS=7TCkn(j@CLAdmd+u)C!`{;u0O3Su(uZlDC@#;>8B<-*F z+WPN(eSlg*(7?xtzmxBnNduoDe%oKQ2IT(fEA1l3-?V@NN$`WeYhF548eiXf?Zh8T zY4KeK-lKc{c$?$}wCe%jd;BP{p~P4zJ9-V zllXyc{BB8nmyZW{NWcj9t;9ER-%fmj`!3?Uxc3sT=kMz!0eS&`;-`2<0pj%ng2d|u zM2Oe(k81w(`QPf&x+q2x=mo@y*9%AxuNROczLDpjD)AV9y?}H{0AqRq8RGQ*zp34)j=C91Z0twIyU~{GW!2mBnB3^HRm3X}YHsbaC?eOaS*Jp2%7vLZX^a7m3 z>jk)o*9&kHKh5)RKJB&jrx)Nk9iZMF^a5Im*9&MT-ln{BhOaK-_58iWk8Ja433^F@ zUVxu^UO<3&y?`L`dIKWF>-k50B*4oHh!L+B5GP(QAVIudK$3XdBWee+4EOWre?1^? zkU(PE8~*xS(c{~RzwtX-0WJgYDfK?4C+I2-VE6JPl;6Ndi2rRtD>z2Ho=@_O9`i4A z#sJ3h#BcbARzQJxy#T9F+C#c`7pi;@XptJZT&aDK0xj3b_3sQ;Df}!${$o>20m%>m5!O7AY+gqZ{Q{K zne(w5c$e<=^S@rOM-SlVf8BQ(ct7zTK1qYb*FUCqD2oyAoBF;MkR$=R&lvc;ftSS6 zp3w8LpV{O5f93$@G4Nf)zn8bTmw3J4pn;F69`jG1y?TPAL4u5d&l`BjV$c)CUYY-Q z@D@0V1K{-vxea`)f%h8tz!|){{-S3HP-aEKz^4s-&cL&>Gxvn8Y@xRP=@2+cf`9S7 z-A%mSL#+nhYv2R(UMrvJ&+F0o0HytG-X{!vn)ob#{~$|zhCgNJiGRY!16X-!7v1{}J^0(2th}Yx$4Sa-n{!RyrpUErpFLkB>_$=|y@Kul}{#ou> zMQP7%JjLe_@te4}!>jXue);i?oFu^u`tl>*#WV5{e;N1f#Q%Zk<2~)Q_4gk_;v&DGVq==dW?V983P#e8~BKUj~nR^lgl{4U~Me6shdK6L)h%K1JBlBjwg#E9R( zJ2Ota9zSK^vuE}`RycD2vo0*{v|hfe+lki;b{Tk&f$vg1)}X!x=^6PA5=0Dq+`y*{ ze74vtdw^bWp*Wz(Tdk!%p~rU^c(;LXJ%d;GAnzFhlszI~;G+gUVc^pSK4&G*KzhsA zqBFPHX5gI$zS+RH&wFhTo*#nV`2eN;r-vqJ;9~|pN&NZ0)AGwu@8bb^8o<4@xU?tq zjO+&9W#Byq-q&Ri;5YCQ10Of=DFdI?{OM;Py=4W11lA>I?g@v1cN_TD5|8oM3-*=- zob~|&A2sj^1D`&VNBJywrT}HW)SS7+HUsZ8@XZFk9bPGaeh1WhqSqin(7?wGeA2*Y zPJ6Zd`60-k4p8bf@1><@?g_hrcNuuk>%3Mz>v~;)T0Zj|_=tgz8~Bug&-x4k3I^V~ z?94skFz{{z-^%^`Gmt*^UV{Vy10N;+i$BnME(}#f3Kz2zz2!HXF@ADO?;D#-!8^ihWKZ% z;s6W@FGKvJe70wamwu?_lOz7eNzLcyz1n`p7Vvdhm=Dk*KEhkZ&MjR9 zzkf*!mxzCcFH0-&Q@lrQ#Q)L96WB?>(!Xd0IEeo;-zS{JSMnCSh+qDkmXDkGdhUJA zB;ZfHWgg-ad|9><|2Usq?ZiLK<989?#aE42^QX`MPw-jYOA>s5C-4*ha8heofOsn( z${_I<@*a(pc#QuXpIp(B0DJ<-yE;bvUcRHniGPicaf0|h-hd?WuPFP0GXGK};IlkI zn)s<#v=(QG{~Mn}S>k(mMRUZT!{V-FP_#a)J6P%@vYkHBLO%3PD{{B{Ac(*!cY8Te1i%QKfuR6Nc?hMp$PZ$ z=l`atR&bOgcq{Mf81d7*C*s7vi#H%ae3UmJS?YakIUloBX#o2&Z$X;)&3qkah`(w` zYe1Ix$Mq8t@wHDV@iG2+5>WY~mQjKD9G^t2wsaM2;$12c|32OmR^li5kl7TE^5+jA zPbam4?Ib~+T2uUaJ>q{nti^X4c$a~97kQ<8rf1Y#6j1Cv2ELW}bNDiDC;l;B(XQ8f zwS49!0j+$}^%6goRH}uKr-=VEADRI1f9I1lNc{N^YWYNJ$@zaKsRcwyf*1Li#fX1| z-y`D0Z{b6hApVE?D$u=t{?GHHQc4ft=l?hH$(1I)hELKA@#DOJEb-z`w1(w~_hons z@+6>}4?%(WNBGT&omaXFcJLNU#9Lm~3bqnosd*o>k$`EwmD-7anzzhBe1XSz5`P`v z4_w4A*bnROL=W&Y~{-r|5F z-)rFg20lRi7x>L4Nc`1Ne*TY;fdA%`F-m+RZ&{4^o!rNXe~qu21o0!hLdo;U`F}m{ zq7+H+fPOC^{%StyGQ?MYR~xb{@%wrF+`QNJA6CZ`4tNB3t!Vpvy;#d@HOZl{z`rebQ1q7URxLOIqu!WhqM{&W6dO> z@;6$AJjB0;7u-txTloWBJMqVOe|Hgo1@8{8>WkLk$MlV#BxvB{<0pO%U(5mGKgaV4 z65q^cYNXgJbKp;W@kfgT;9t=n0*L=TKUKtuck+W{g7{}1)_OEad;t?ior5V7u#b0X zns^sqA{pZA`Mozw{IB?aog@B2egny$PuAdJKE?%-U^_2>@pl*XZSzZf843s7ka+!1XvPe@FKG~vG4PVTbk`Z?1v`jODSu4|zMRDSU(_n<(){UX@aP}3 z05?f6$$c~NjSI8_+KKP3)_fQ7!`ypIJjQ?5LM=gWNdW%3ij!&sSdjQr6WaMGM!X(B zNxc52_%mnn%KVd7mUig?|MbB@yxyhF#P8ytN^}uFc&^qkFY)`oq%|x6ug-scIsK7u znF*4h;X*B=B=L{)S)DQPdE)i_r3>d9tgSyizU-LNw&sNR1SdFO1xA_bLoWv*BYD3^6KE}P9cPjb3f_sU0Js&IaV?4gI$gA@|&s*#%3Q(qLH*Z-h@p}Ar;%)qO7x8+9yu?pjTBWVO zUJ{_U%ul?YQGj^8WkKTOd=ABkpK8#0I$q~1?NU8KiX_PL1Zm>+j55UQ70D8Road9% zz5Wayk|zG{ zF)gDU@%rA*)|3v7?rp_hng4e^pk?GN4uH}(oMz%1d4)X0*KyxUe3tiUJMr&M^Yed@ z1nlA&MTl?W8AXZj;yyoe;LP_Esz6Z3) zl_3H7i?rV~$r9i2F3snNA9%Os^VI)bYl!41?KuZ8*JmdINr#q^gZLOPz)5_(O^fd$ zUN68yeA8MjeyiqBpZ~{rM(rel?ItaOpLqK^ZB7J;53bOBjQBbpKVIT7{x#=o2@)j% zY<^Cp4SbIHdS1~y@pY@T1{8?5DPEm_YfHywhEF0J@%kik60a|(X5#h9+fMvH_{P-> zua>Xxfc5;X>>x>?`xx;Z-_|B?lK6>BwHBv{kGnLVKJB&jH_N*&b2>nswtB(R#ic#e zzFy17O8me&&D)4S&b^)Zz&f9nz(E3Xe0jL3=d-t&c(y~!$3uLJTl1~N*YjoE?jr#v z{?0`&@%x_9)^X6l$B2KFKjS40e1`k^^Z)hy&7(X?kmdzT>q>_}_jUvCA-;t#zt&Rk zWBygz@@p>*VD7JIE$$`$XTRb-Vc=uL7xXuL&*(A#GG`3HXLAN#sxR${y}#EgWG7yq zyeNoHa;`J7%h_Bh8O~!PQSJ$7HFQ-gVfYK(BC&&^n zbDtx=W{*}to_OnS%@>Gw?OmX)f407~OPjx{Z7eq8#cf(fcH+mjXx>45$0eF~5I)m9>9GEUp7(V zXD-rKO_F$Pz2;NI*ElqvCf;}IXIhPNBtYK}Y#U04riK?_C%$8?R)E96HxqBy^6@bb z3GniaS`BMqYYVH^_YM9^0PjvB}kD3`@g68Eb$`0`xS_< z=RL$WmiC-JWKywL=D&U$wHF7#%eiGHo1C9zyLqD2Z^8L8%WH+CyD<>PRl=I;C*?60BKWc zPv{xhiC_LFtpFGCxAE2FG4NfQKYjk!3-FT!x{nz6xPea*uU9Br;xYbT<~6l$E^V>? zwz`dYJ-(B8{h6?tcs+jmnY=Rpde0O9A2jeW;$NB4h9GI+^Tdx&YRlESg`EGbJiv(n z0WkxgH1HV%pEvN*C0b9+pa1om+IfIJ_PTc&c#nba zGVuOV?_+uakZQj zha{-x8FdlAnR_qsJGt*AegpS@-RtN7ckqCq9>CB4F76}5H*+5){zmRqK)sg@+( zrw3$6fbR3emw!v^I>}W!#=5r?e}bQ$U1#<<|DQR4brG*u$Zy~y20m`!Q>w@O)0dxK zK-M5Z!N6NDE$sj_$m1B$%Yzy}O`)W9ds;MMh)K0|;q$~gniE<1CJ zZ3fdRdPa6FV18NZ-bMTe|D<)bhxqkL&374ie~HKV zU-LyRL8K($w2u=%#(N?`{Dj^^#E)~IBK|(5TxI@cNx-3>Y85INcg70}`hL)GChn z_@pDg&#(Cu@e%IR#K*YL5TEe#0(H^wwKm4!T&gPmUxl>-d~RR zUURuxp*->Z{8_P3>V53hKl7uGyR_imPin6v;)i!>Z!}toZ@O7)fQ|UbUd=m*FWjl* zkNM{$0oD&|1-po6+`EaFxNj!jtv@vrpE#&hs8#XG{MVP?be9&;P7(yK*L)Z8dW*fp zPjqYXdx;P5-v{&;d3F6Y^504c6a^@Q=;A&|yp0b{lz12SG2$mHv?rlB@m-(f_x}V5 zXyEUBCW&va*D^{GAJ9KoB)*wfC_{YfWm-O2w@({ueF(gK%yJ|_ANP6U<9u%~5O3p? zj_oMT-}-HBX!7dh!_e#Jf2Z>A%Hd0!m8a)KOSkr0vHIlGD;nTlpM1JcbMN4jQuh-X z?LA``@xI7!v;c{Bm7YLat#y%=c)3IK`W(>X>vu^ziEsau)&q_+dmnSsfV;E=`Z5#* zJff?8qy4b+t`@LuEH3hqBJU{jc0L3q#79>1Uk=q{{wo2dd5!&`D1o`iyNW#DveZy_ zk+&4ZZ!YrM;A(-MB44hqKc1+yC_pO8sJ+Nn6#1?q&u57m>Miox;A)9_i@dgnYWolK z7X@get0f2&d2QZmK3L?n!P0!B$QKV;w8$^gqN+b*JNWunhG0oiK)fhHO_5I&`K3iZ zS>%@$`BafVr^u)0J^J5P6p)z@U~GAj&ldS}i+rxguPE~QB41nN3q}4s#m|rbPOT%A zo;bfKfmGx#DDu`KZ!hw;BEPc8+l%~#Mc(Hq3RqPX;4JcWMc!59R~LDAkzZ5fn~VHK zMc$)%pSt{t*K2E0g0)2n+Kc?fMZT-ZuPgH2B41zRdyD+~)BTS>*jW^?p(sJ1$Zst2 z!6NT0@{uCHsmMo*{AS%N;~y&u*rEr3@gm<)`31s>p9E@@aUrp~|Fl z6$NC95?os3vpYlQ-o2viE$lOIfBRcPZ@T*i>m2{mS9K_Ox7}KQ_{~3B_SNR|uYPT$ z>%QOr@L^-;u)` zI&L}qF3bG~_OzCN@TYIO=dt%nU%N;<&GLWs8-3At(?yT`{o49luBdIUb6jxs6IV_R zzyH?QOW#`F-nJriWwrXJ4(~vPm>u8jHDNB~zt?T;ZhheT@7{7zs^3zz;;#PmODB@| znnm9gm$lt+wDZW(wzoF5?7X&Z!;xc0dyaYAdJZ4%-q*F~z@ejUM|<|{?e1LP>)N_| zYr}f)zV){pIMi|YmZR%$K6I?D`@r6fN)m-fkL**g6?tUOky|&a{}QRHkMmY;R4oQe9gPIGMf!q9iV^r3&-Fo)_NUw&fCmvd*|$FKZm zWoSk0=Wjd9f!(ODk$s1cbgE0_(6NIXJC7VW9J=GRmEZpAHKw!fqxb)EP3Xh%W$O+e zICS8HyASss=;&PU+;HiJO=|WB4(;#W)6==}@Ub56v7X&~kL};zdE~^EUw^0R-_xl# z{p6xe!evRO_RGeOPVdo;|8vEZjyO^M&DEyRTkc=j)Ud&+?I)dkI?z{#I+Z1OmVK=3 z6IBNeZ#;rLl;k1%I$_b);GQJGGD)D_Ts*>@!;W(6W>aVnBI7QJ(O8<KN z^NIVv{~aOpskp6r8((Y34jt&(a3plY4?0drKfKbk^NsGm^MxGk=}>3?d>Nsal4rkZ zKKIa)(C1&+8M^ABoBus){G6BV-~RC>!r65E68X@+joo`bcek*8UL1JMf(GrPt2tn{!JV2l|QWwjXYm_;=W(p zau!|ttv_CH);An%JKHvPA364a*_tnZbam*0=gtZ3xW8su$zH!_@7^PwA5eBO=L>ep zr7nXHI76>KdOURRV^?lXCQTdc{I2rGcaBi*2lf*!kKZE*mxSX_i%UZzPi#2r<~kGS znU#7y_e9qlpYfMJy3vdcD)jMqZI!+~;C6Q6?q7cQEHq(7v7nhYy9W$XpxxL+00^>Zd<&;_;`~2q#ufenU|1(PoTW z=#!70`^IagoZG+ge0gYRY)L3KeR1fvXD>Y={0_M_zED%44Kb!Z+zFR}_=j|sRe&H{|iTCEtJBvP1HqW&$SBD<^n{)e^X2Q$rlVJfVqaQt)>B+*^!H9whQiaW zP5*y92!>w$jXiY#cvI+=Z`YjI_R5b146FUb^?!Onxu^}@|60h}ad_XcgPn(Zyhjdu zJCF3-8uI*kNPR=<@ZR_CQ=a>AcI;NzbNHZ2?=45Wl-K(XcXX=k*5d6ye56r%eQeWa z^?ihc$9n9_>%DlARj9vhf?IZn^7_3;4-s>JKqb3~2bbL5!n*WBFmCdD7bQ?x=nP9ITtFzOm!L z2b9;1E7z}AXkUN1U1852-_iFe7e|imy;b4SJxA37t~q>2O?b5Hz(K|D-J|tH{hKyi zro6s&5AUI)2h|2B?=mR<=sqod*O5-Og$MV%PtB+2RW$Y>_}(5eb3&* zM|yBhsyy-ande^o&yW9Bz_;n-$A4>1K6}#hJ+ZF_<9)}Yr{=D&o}FuZ@zk9A{MosN zve`N3IkR)mynJeI!PBSaZbkU7pFA~p%}a<^I5n5I&dzPEn4O!1o5A(|=TFU5EuEcP zkv%o{E`)#fzfa9=Mfz)y=XUTzr%ugXwPJS8^6II%b7xM?-TuU>xn;=r-AiWY{64(6 zOP-y(8{cd4r{)?__$)HL5rzE@1uR6y%MtkkWX}F}YVN~`e-je+p;A@I@E{6*Ckl$7 z&=(PZ*E6T)>TsRI_05R49ToMU@H(V_3o3I3^1lez&1mQx$|G?aerlJHN`;(?R#C&Wo1{KRwO zl5p}#@w{;Vlj6$oGf#@Y@X7eNA;><*?qI|0o9t`s+w4;;&U)Bp;q9jTgbxZQgu}u& zg&Tx-3jM;{g%1c<@{cF(6uv0z6h4AiwX8xmFJ8LPR)%*8+3jqWeU=5-3+xB%x9moC zHS1=3+1ir-)(FaLyW)IoA=}B8szQrYF8qSkvrE~7<>lpHVMF+N@?^QCJbqGrVJZJw zc{#J3und7iC%^IyrhIqY_~MH%zMH-G#TS?1Kjmv#-HYtSW!g1({|g;AhCUobl#hI5 z*{7BbegrR;ZT}Rz%eQRVhd*`Sr-FAaTNeCC=jn&S~x0v3iD!E=n#~> z;40y>LP}W4u3&A#X7-qHQaHq(6aFfEk8L#VW@eZ04!pfxZMufNBW&5|=DPi26T-dZkAAM);0jiM8S2uf%fmHy8{1-(fnphOyPA(*Lv&J7(=_ zjFb{uJTi=(8`(rWRFUlr%4<mrC`de|*WI43wVCT{BAD`awAKJMr%;Z#dN1>+RgvgL}=cmg~=O-&TtYe>^Mxw-29QuUoZt z-G%Gcty{20ShucLzF=MT!WxHT-FYi)%NN}zrV8AvzG=F`WERZkUz+UZM}$||$4pCvt>#~{Hw%x89i2yeaBw>LnHR*5FHuKt zmHjsRh7I=H>(!qdHdNjYQE$J@ZojRvzH+U@fuAQY|A%;SnGf&z?(IB+Tl`^r_nsp+ zcPcmg{RfcWAv-=Bxw*5)uAI?|VjVq*&s6@$ShpNL*4<&>+iCw`=MiLf_=x?`;X~`y z7e#sDq<83WPosU0-Fx^J)Zp-bd(SP0oxRs}?!TfFCG6=|-ZZ_{j-%TVMCkDUV{YzF zeD~p7S+gcn`N?0;i60SsV>ko-kr>Q zHFpK#eDm2;bGLo&)Z9H^I5qcHy#CDBPx!;?%;je+)iJo{I*8>x$=Jq{&YR-oiy%{YxBkkM33()G% zqF4TgUicY$cNtEXANbL!xhIk4^Jw`U_uv}&d(iNI`VeU`y7SivoSPG@Y!EYjP>>S> ziwXEd7|y|h_y93jUNcz5a+2I%#l|b-sY;fq@FQ-fQi9J`9#`XTO^A*075InGq`?&| zZ*9SKbg>j&!6HlLzzUXJCTGuON`32DZl&a3&-z!@L^iO|Rnp9QmR}<$>)Gs@0P^fxXHBnX zk#*MidN#aH4z6dT>zw_FvrZmEZuN4op7qy{V}ECJgNp>Y#TNdF*|OL-*jFa}%fhBn zlWD=)-@#_lYBppNaTzmt8H<>$$-Vdo(CZnSFiWw$Y|`A1PRUi)4(w%pRfs%XrCg?} z(q_b7XdOqwg-ZX#tTmYqHfkNlb#9R@*};N~?Ffx8j#jbK?W)jIV4&^$yRvhkN18ebcI=)tdm|i<&%671HeFR13Hj1-Q8=LTv zyYo0Mhd1GOHd32va5=e2snqZ#N-nXj*1kO~u@&)Vw<@B4n*%4Tk!=zpj&7SqC&n6; zY795_p_}5Di~XH!LdlLedX$fC(QzK2aU$-{eC-&<<3dsyTxh%xaNF}R;izD3M-GGF#BW2oWKmGa0Q z7QYfL%w1{4W#P&?OvbB3Tt=^&MNbS~t?1#aq0?6@dg5xOU;WqGvh6H#EzUndPc6zB z_C%52`1Lh?oowoQIoZx;t`|q!+3fXV4n4ffnrLUSUFe{(T_P@hsa?o%TGg|=FzbhJ zPzGxJh8_ft-B_1G@4v$lZD)yhU|q0$t2Kr6@04eDvypdV>?hwTK}@}K6oJuqTLbMZ z{_Zx$`rDMt!8VkWXcKWc+U7>Lw<&EHc#qt-j}5)24%aE)du;d(>i5*+y8kBRH*!<6 za=E(}#SiZmk@v`MC98?uGAc2(TZYf?R$4x|M~dwchV~Sc>wR)=pOD%oP3{xY`;cR9 zpER>inB6A@I)uKCF0`;iDs%`l9hlHlol^gPVY+h{Mst6IIJ;jM+mBJf(Nz6Xw}LD( z%?dThdjTUMV85Ud*qgAY}zSEucTOdLmn?MrvlY9;J3m z1#7+3C$NYhr3E3XRGBwN4eOCo0-7U5OhQg*2!BGd+!%hP+_K53WIrrOlY%fJV11XS zNeV0@NCA_Ooev8?D_QK}dnC)bVOxbI<#UTe0_zuQVgifuEmNwK+E_iW)k3%AMWw{J zAPfrVkNG|xTq&GeFJY5tP^!=*Vk7Jm>d?fPP&5=u=dEHRSZX6CDPm!>CfRRcIkPls zVMC%k=(Di6sLTa)!AkVaFqZJJXdNzNqv8y7yi7`#35ha!zGN+)^0zxoiB|lM^^7>C z3~HH_Die~gi}%0fhvsM*4xx%erW#Z$VG3aRB`xxVMHsgf7kr*z>leg?APfkoGx`HJ z8X+NwNkKn>DYNWn60o6R||VF#w7QZDa#JmF|#hI&h_;M{ z4O?nqQsIZKmg{}F1#Aji)dJ)z4^*=u$%;R^F(FB#3s_c?3o^?~@{G*<74q-`)X;@E zlNDY01#G-hG5u9?dI9E;Es1=pnh-i7E7xh+j_bh%F=4z~7^|KiuDQWZq1|hptYjFD zTm^f3O0Xu;Xp8`MW0Q3VJ!O)T6)bI@5N0c|F2!Ia>nj^nUd%}HPzCF&sKs@(LOF`X zE2Ys&VZ2hxR0_FD$zLVp!!Iqee0!ml_P^`>+CU&zH%u&8$$K76O}Dssa-Xf5cdx+{~sI zjA5z7Yo*|3HoMX~x`n0dq$wv0u9lOV*x(vZY7qxIL7Q;e@yFz0WcOrCnz1p`Zpj*OTJ0M7YGpvLq6)=gTW;SYaV`dMVrG!}+G2`Yr zYxYS)qQG|tQMn!ztwjYWJ4&}SVPa{)I%Z;ilX4v}xp9g#*>bo6nu=yz#4LQq%&_a# zWz17oU`l1o<2!NFSGshpN*dXLu4%x*OqMe{Sid}o-i!GbNMk$M)I#g@4mPt;IV;5% ziK!iIWD%A)n^-LMx!L664vg3mG2&)pO9t^9nb|sVW;>gz3nJs0HDX~qE384cj9;Wg zNneCA`W(t-z|o4x4x8W2M%IdBZkAX(gzaUrUYvHbY<&+3@U0hfZkAb(gHUpVnAyq3 zHpGzYw9^{c$!48)?1Y=7sGALKLg%J8iNibC#3pHI2lH>1k~`S+W^u&L@|&@NCAY-P z(VZ-}HG%ZeOKT81a%mdZeT_A9=eM)OcDa8COKrDKZ)cO+<=O2%7H~@gZehqR z#oa=}EhgQR6SW zuVTZpG%5=t^6BX@C!CYB*pSj+hP{zxs;qvQ<*G1lEZktTyv^5R#d_*f-T*GBhbw>a z-0Bo=LKr$zkOwN1nIJ> zM#({mjg?Dj+*~AEutEq_G-29Q$ODyvFFb3rY;|rD)-m*Whde4Wuey~B))?-WrUDA_ zi!yGI1ALtzsgH$kT5fSI#@P#}mT{2{nq;iDm?;;IEw`*$q)c%HAg?h&#(JI#|8BWu zyAOE)(DSYU~qU&10w;hkYw7@7bh` zq!}aW8Z@&$Q&Q;-OgQzhitAxKO<;!NNQgyhl9jC{yr9;ycGD%oM!%^hfqRNcITDSV zrGDIHFn2MfM0o`FVH|%gIKK8`6RZ8|oTPEW6Q0nI+i=vI#$P~S6GvEY|!2cA73dUZDQq+(g7E?;ySs?mPVPY za2v_3vS#bp>?(P(j`i1BC+b+VP9Cpg@j4k%M(dOtL-+?5SXTLP_lpa-{irKj#LAYU z7M3^F1T3u2tQ(ZDpm32c^EQXt3l`vW* zk5^&Ol%}eL$#D5fOS2DKQ@?;)5AOWq5_aY~>{4;FG$>)Kk#LO@c0|I1n{pe$UaD^~bF~1ip z^9qmcnebyPE$e(~tRIUsWf3Nep30Oc0YSJ!=<(ziWA}=n29suM{}MK1mWCIzBu>YR zSz0`fC1|nXvahTru^4N^np@1`l67P;8>z6y5T(*OxR}jWS^F2W3E9!NnDtlJ1Q5Eg z0oT(D2UZI4#n|}d^kO!;SRP+2q!#0D8d)NZEfJzi99SsfFI3D!Y2JEPiSTfb#|Uf$W;^b% zBg!-IlvysA@gP!IZMk#ltci75v2RZZb!bMPN!eUt;ZLuzT(bx_7!0Y5C*onVJZff% zldrC^TqJDAbG2d$I2fCi+xECwoRG*$i+%6KL6%SIFwRusP6tO$;Lmg(4WS3f) zqXD&^6E1Yd1kAmR`hG!Borh6#D~?p=Cg@R7xiu^CE_O;oX6C{W^)}#QSU`?bcoxA_ zJ5Ny0J*GOmP*;MQplTXV;U-oHFIjK-qm15G9)A1rWMMv8vB7d2#~X{7FXJmPyt2x2 z?k+2v#h?9~T__G(ncu4P>YPy7BjZ->7qCOBuaOg;tg&LyQ%blzi1}WPWj>C%hu4c@ zYP<8I^8VJW=m`H}Bi>?rVxy&DfpY8{5%4U7+h!oV*lBs|vH?_Uz$DI?u#LAUw|BW< zV)^i4r{#i${U+9|-18@d21E;lKj*Z#@m2!XydWVg63#g-{~3OAlcgs7;Z2t7s!&aw zHYJqOAI@&FtWbiMg`e7FSsY%q*>YTa`5jzUDY^Cw%Da*S;l?eNjp6bw$mD%nEC;U} zH!G({+`NWF78S9X@m3F*tvD18nUy09TDdr2vd)&_xzbuFV}UZ|(4+cgXoAuVYYe-6 znRN)K+VI5TSc5B+n?3>x=eA)7S#ybH z?^PJqWjM=#!G0^7g@7y&21AutEV!C5d1 z)?UNd@mrA(SOB}P!2)gnASMsQBp3&4Zxh%!*a}XAW1xI3{-Ex0v=j7zEnoob0^?vG zmt$gam<#Eh-R4ju=az!|U$Z2uSv2BY9Gm<7|I{p0xDK?w(K*h5A@ z7dZ0?fwh8-pG1Sf)TagJi{T=Ji%~EKra`>fi_dgWQ&74A6#{F)7SIXygH7N#*b3&s z9u_n+Cwu26^2doDJU^_Sj4h&%Ilen0Km;~8pu?j#D zlyOIJf%RY)*a8lM-QX1158CcR*MlA~4F+@iFjZmI37lWKzy3P`oRV;u6Q3V z#&FSkH(CVtf-_(Qw7v^>G_W4bfX!e5>;P@|;06J@z(KGTOn|-M1Q-GHU;>oijr5=c zEP#!m?Osem(C5NM2QFH{Ua%MJ2P0q8`^ftG;{V0Q{#3eJLlu<_@pAlL-Pz-Dk1Yys1t2b=-}U;(r}f(@+`%lr|n|7Kj& z{{mYf=mqh?Gn)V-VDmUy4EBLpFageh(xd39{irDD0LQ>)umE;|b&sK9U>6t#6JQdw zJ&t@pH#iFpK92QYdow0U8XE?f0^7kXcpMy@K#zdqU<#c1BU%88FJoEZ0m(a!p#sPM zgpCWF2EAZw9z6mEz!5M3rok*Y4bFnn0W{qA8YUSo+@K5W0$V`o&!`C410DyHU<8!^ zf(*e%FbyWaJlHye{o=iN4LZS=zhe1;InWC>{|#F+I0VK)_IIQQeXY32<01}P--kI+ zKn7qR=mDoeFIe{vv+*Q4m-I*34qeK{wb4M!{CF@n4wrVC{dSr@$DP02AOi zm<4m7G>7GV2=@}ue#nQK;-V4454M6MU^iIHa3TYv;4nA|ra-4)VmYuCoCSx$+QXQ9 zU?b=@nOGY*03HW(;2J~pHT zKqQrbY7?7M0zmw#JevV)LGMBnbAzK`8#oIpe?+9$ik<++zy#R72n_)v;I!gF=@|AC zuojdSV~9X2=mDD+`%J7G7t;{^V7vxh3VN5CSQZ?=z{IQ{K!z*P6JXbcXaN`l{op7# z2#TvPwxD|((t*Puy9Earuoi4`p#k8S+r+$J?+!dnfob0rm{hoEc?%vJ!60bs#d3Np zx(@6Bo52j&4SJg~c3=#QgY8$LKyao76}=VduQo9!SaYq3?E=Skp`yy`8&FYDx)D7E z4z^(ugTC%|6!<}G4Es6!uogNq7`Bn z*aLdOAUF)h!H)azh6$Je^PoFwV#*(!=?9%)`xi{C9ZZ6KV8i`r0O$wDz)>&@4tx>I z6U>3Neyo2y_ci)(G5clo04RS28wO|tgJ2yv1UkV4*a(h;&0rSX1c&^oTh zL0r^*2Rj+q45q*qa1tB^*+)^42T?I_9Bc+XC$SvCJQxJq6Q~dv1XJJ)I1P4u7t8WL zQ6aD%JpMh*H82i#g9RThg1Bh=J~9BaU<#}o#jFKA;4BybtpS|fet<3pdq5931a^ZH zU;woK5Dftv!4#MWbKvZcFvK6j`cGo^gYBRj^bO;p9Tzj8A8dZega=C06dVN`e~b)3 zFDQN-8T_0_V5q=4&;zw z8H_Dx2OVGoSPwRXPOuGZ0KK3K>;oIY2d^}o=C zU>(>7y1;g@1?&LZ!7i`|>;?m%7aRn8z&O|oj)BL)4Cn`^!9I|M&=XH${ny~4AHo3! z!A5WZ^nekt0~`cq6z$91?ra(8C_Tgd| zE;3*jm<5l6IWP$3!6C2!CP3v^OUA)wFbB4PvtS!&{VXa1)`M}-4UU7mz#P~GiccYb z&<+McUjr_NaM1!Lz;JIf~}w(>;|2nAM}6& zU^^HC`@vB#3a0PE`X9x`6hsy*fCW$vqou#bBm-?=16U6>gKn@5Yy-WZ7wiKAU<4cl zhrwYm2`0e_a01MMQ{XH(16m_k|8fSCEP{eS7w7<6zy`1#bb~!$D;NMfz(KGFjDvn~ z3>*M6U<{lFM?w6-LzV(-zzpaB^I#(={RVT)hYLF{IzSiL3wpqQunUZWz2FEK08?ND zoCJr!JU9Z1_n;uq24=u|FbBH90=Ns5p2i#iYr*4SJ?IPKq6HU2U>BGG1K>C~2xh@J zI0L3Z`CbeaXb1CP11SF%Lj%@=ZD2j<1zlht*aAktc5oQ04<nAJ1*2dZjDb_&2v`6|LHR!1J3u>_1{=Uhuo=vO zZJ_uph6c2QeW1^YiwG{<;4tU`lVCeI0Uif)U_Uqu4uaMwdJL=sM?n{u0$acdupP{T zJ>U!&0NL*_G@uN|K^r(0#rn77A_L(7r@;o0eE}5$YrtmE0eZkjuod)x9bgC81NMS_ zU_UqjM!_gJ0uF;IFab`2V_+UkgW~-@)D#!C`>~^e_23NX24}%tpq#}V0BgbHpaTqo z4d4*y1`}X2I1cUtvtS1}1NMN@7txboEjR!=!6C58hl^ocw1OjGH<$UCN*tmjVx=uADru=7t!v!0v|TGk(ylGm)ndCU>Kciz?bc%b-ZR7Q z&Ys?Ho+o+F@11kboH>7n0hYNgIEH>Wh2oze-Gd3ZfLZ9;V-7$+tivGez%U%bEF8l+ zoWUkMfIVpKG6|s@PGA7;mPtfN9Ka-0zQ!DYc36QP*nk1pg%P+3lW+nva1Z9-AuK`r zhd4v%fejdhZ5V}Jn1TbChvh928zjcC33p%*?!hg%4|m`poI~a7%mHYJjt{dcpcjVW zI*h>>Ou{tGzyhqoDr~?OY{UMCS^onPBg7`0!V%nuQ>c7|a5?7%SW!vx%fS-1;Ja1QIx zI%f_*9~{CU9K!^h!8AO8IcWVT$AfOT0Rv?cbrKQSgh|+iIXHwBxD6X{3cGL*Zo+*y zflIgtt>0u0Ko_)sj0QmuT!%pzg;AJ zp%3<82o7N!Zo>@RfkiljH8_WDxPSwwe49A{?Qj>mzRmhyknkZ)_s~_)1*6aplQ0ak zFae7&3#+gMo3IYMumgv12)E%FPT>sB;Q{2w9E$Z%IWTm?K$%2!DK3wB`_Zo&baz%953$M6vDLHnOEH=qYPzr!4Wei()8Fa^sI5_uAFxB*kJ3A3;V z^Kc86;0~<9IjqAaY(d9>)dIb+57*%k#^4rA!);iAJFp6OVQawppOfe#7H|ZYa0*Qi zG6$d?Dt}IwK_~P;AN0cz48k}J!wihUB22&NVVH2*y4vfJeOv4c@zzM9v-39BvMPiQV z!zCO+>qE=|=!E;w1C_s|r=Sytp%2Dj2qs}1W?%;9VG)*K4OU?rw%`Ev;WpfSi1oiq zVjHo5Gid!J%LlsQ90uS%jKBp<{Z4%gulhN0uH z8AIrW3AheZFa`@S4NI^9tFQ{|umxMN4?A!K`)~?3;Xd4k+<&tkNjTvi^uYxTLH`q& z3$Or-un!y18Zawen0O);Lgh(j#q>9HG4#S1T!%v#gPSlde2_oEgA>?*&L^A8N|!|G z$#gYb!aW##idj+qmPrYn(D76n1UFz3I@f6s%)uITKaDPhakvH3a0f2n0qi~9thoM; zGlqV+`3$oXhC9#TH^2!Jxe#3lhp;Z5KiC{?m^#8G;EvY1f8$~eXt8da0|xa z0n9=Bvp8eug$)>jU08%8*ejFRBjF4)xu9}0%jh$VC0vJf7=y8A(?DpAFt#ubH(?%5 zU>oki)N>e$zvo1t7f#?hZ2UBn5-QK7LC_R6^XC_={}_oa5}u!-0@!&Tv-`7j`Onf- zum_`X2vcwi=Ak`CPr(lC3ud{{z&&^%_TS3n9n;k?00Y0qiNH3@!8xps#rl60S1v>W zZoyfeA2q<#?R4?yC;$U+3L`N28YUCmfF;<4O|k#A^a$K8u!>;fH#m{|=}G8;+b{$} zucJKd!P5P#fBSDzfyfAla0qvxr$kqNp5+K#un9wO2;(CDU4{f!U(eXWZPA=s^OLKDs$2B7~9^hlY+4v8EL{~og$n%+o7a35~N*akxbQ*Yvo9$*r_nMnu3 zZ)G*W4OoQRumRm~V@O~Ij$seZq3i9O&_A$@p%0pBtp6y9Fo_J@g&WZO4u%9K;1(Rh zJ(ztbv;QAqoil(P7=aU*gq3&E5IBYnSo{Ne6mG*Q^foBJ!+k)L^6;R^`j3$C-N~51 z{Ck-ca1(Z*<9##$c3WJr;0#*7z+}3MRRTxvrwic$Oha#*F@;Uog6KoV1;*hB zW?T4AC_jbeKjXk8QlF#%EWj3QeTuPz<9jIp=g|2@u8g1N z#ReY!73Bo~njwSD5m!Ly{Tmt#`+vuzgZt3*FXV$RXy0a8Ll+FgvX4Z9#5&BvI4r>w ztivqqzych?3LL{4oWV9cfCFg#5@!V6a0Y{L0i$pUQ_%Dot{>12H@?LB_mOBK0e~G?l+X3rV=( z4)nu048sGMfY#44G|&Z0FaR4c47)G}H(?S^Uz zDcFN~IDi{)6E@)l_TU0;!6n>*_Ax^P-EaxL&@p2bK`#u#br^;*7=>vVhXt4%v;M0j z(ufw!!9Fa)5!`@N*ns=64VACbh0qDNpbt)92+m*}E?@>KpJQmC1JFxVFGT#EZl}AIDvIIg&nvDhj1T`;Q^e%B|Lzp z^8GBEf2W1e4c#yReJ}!pFbTsj2cxh8{B3}#>& z=3(J$tp5!XRYViEUUZ!x>b*&MJXU7@W{!umxjq6Q-f{0fq!RU>(L` zA0EOnwEqJ`@%1vdUjInfe1kK99_WKXxDKN*3R5r+^DqfFU>Y`I4)$OXZovxNfi*aX z9k_&D=$JDnU;tMCiH4R*bV-!p6gJ@@>_Oi@GdW=f?!hiJeUl2H7pA9lEzH3f?7%GS z!4e$6I^2OBxc)^N2;*=ER$%#nM4g27TO0to;T8H` z;T-P6B|L(C2hFafhL`z6+YiNp-ig7IC>05;(iF5v<6eVHEm4znA& zVHgHr0!Cm8CSeBVU=CJb8@Avc9Kyy|xX!=@Tta{OU%9|MNK0T4c3>QOXY>H9!W!(t zF5HGA=>96R7Z%_W?nBpiDfe&G10DZPgJ1|2U=G&dA?!o<9;*PB{Ul~2g75$)p<}^N zKtCM7C>+5Q+=h9$12^ClHlg!tTvDM6ZbLWRg$A=z|>?f>W4;wQtZ9a340{0qlN{_0Mx_ zlud*cPDBQH0GH7EeJ&K=WXxe0uERV`!Z9queb|Bv*cbd3Lj*(LW)8tNT*4)EAFvws zX&6kxc$q|oLSI4q+dT;0PYVDfEAbDkpU%&*J?~o5;1unw08tp5%P#}61&=!9dKgZr=wt%r;y^uj$Dh6|X0OPGb$|KLoa z6V~A_?7{4cl-AH{l_i zLe~#z=nv@u=z|Ryf=w8Q3z&zF|E3-ofL)j`lh`J)1LtrKmyl;ND6aperO*$(Fbu;m z1tTyI<8T9}U=yZc4`$&O%)uR4gmbt7m#_&POV)pfgqK7guEQaW!7Z4EV_1NDunL#3 z1-(CFa>8{uf?+s?akvkYQ27xJflgR~KG=jI*n#mMvHtrcGKfuBgdv?@R#_Tez+hG{Fur6KO6uy@ddL**@vl1ElTK@`PIaY7NrJ*FR>^in0l#2 z@&6C&KbW>Cj{n0CL|$&;mr!K*6^l}!qHWkB?qw`Wk9gx1Jn#+fL-&ulA^BDEL-#AG z7zSV)HsChwz+E_i3#eo*ic8^F5pa)sJ-1qvU^&H)4}Q&}gxN8N8!-DSDuDaYei6S` z$XOH*^uap$t8g2dUQGq~Hq1iDZJfx9`GE>d!uYR~9|rOko{`9LZs&wVemD~Ky~e`t zzh1&mWk}@V4DzFR#rs-|;=Pz355W{1!#s2sEc|AeUq8VS?7}Iu{)UCe{a&PapcBqu z5~g3r0pS?#LicZ46qAV_hAtQ@TKN2r1Dk%!!mk|or5nt`5^TX1?8C%wi)ZcevkW*R zfBbhW{0f5KdqCGq+5dVPOgwr64TZxuGBo(*o0$W|*0+$~!tY0*7w*25d~mN`X2%*o znR^!vfUWm%hOneE=CIzh@B|6sJ1xqN*#BM&4_@QI@8fs;93a$UY~d98()>c^E=~ZZ z@8(y;(EWaTl>BoTv~rxbMajX=2P{elcFTWA*N`ZDkgl^aR)5R^C>Z-N6~2rHe4Gkl zaX`b#PzpW~A>81BF{+}^Y(B{=#NCm`Z`={dnqTX=R4lX#1Ip?$<$JHurEJ0>eE zZ+(_Ac^JO|`aH9e1H}HBt|Q)ov6s`;U*stT#1q&i?tF=!AP#Rdxrl8{?DugXKfCHGMqRf9VM?{7H#T@!oe#G=cDrEo0f3q4ML4JO$ z9)x`ug~|V6dBcq#GvsiotSO5!3Exkw@#{6tz_O;89IOKCni7NE%hr@8j9;;)@W@fc zbmf{dg~hAZlsSGEx*mCv626-9umg+G>fzZ`FbAh_0Nu;mk z*$r?6525e6HN|%+>l`Lw%)6%eU&&D1M1zR;!fQ$oHf~;1cA@j9ITP4_?wS&GQt{8M zDLLqWAx~a`-k)DnW-#}Q)O#7nd(j%du49h<7XJcv4$=SZH7=7}@hWRdo7lg>8N!V> zttm@bdNXHuIkWg(JpB-M-?yez9wnCFn$mz1*oD0h(cmjNkx$Z-S?c=~Jqfq(T~j*v z;HPOYY{NP}yj5OPoM)LVf4!#cAVz<)rUY)K!oQ=1c<=U_GKBHZFa&t#XK5I$jMunq z!_TcLUN>C|!_fKpHKhPOFvI@jgaZ*r9-ut&;XhCx^zrPiU3|U#g*7E|j>JE4CNTKV zbQw&*IoyDztC)mS8U`CM1w&t?C!p(JXgF*^`>R-XyK9PaHIw?wjV@pI^YH8IZ#nGx6E(!=`SB;mM*E_;yK3;nPL*I^r0-~{f$LwE@7*RmSE zOG5;C$WH+}VHI}a2=?I=4&gowKeVPKXh8A@Om>+44<_SvWtPh!%i%iC5SC#3zZfIf zg<}}_Aw%G0ox^qL|8FK0OuzyxE}49A33p-eN94bruD{r-xNl=w{)AOY!=A~iB=HAp zR%Ht353}@Na18yAwJI~X z4i8}Dv1O}by^$6n+%O3PFaskn50kJ6bFc<0unik<0K0G-Zo&ziz!}_w3wQ_*q5ZK8 zwco0EAkSM?g3wha5hdY+DHwryn1&lL51X(8d$10-U>okhA)LbzT*6)G@H4rf7b=fq zNT40YpbMs<7ZzYSK%z=w9kyT?_F)W;U=mJY2JXW=R366=K_{$2A8f!7Y{NM0!3-S2 zA{@aQoWM5RgM-Jh{troPBTSEH4nPN7Krb{so`yjej6pw4!!Rts1gyX;Y`_xi!aB^H zr`+u{2zH3K;20jl0~mNBL;eKTf09Jt2~4ghF$9nsH6>2L(UTct7=8+43VG7I;(iU2 z=xH1fj-Sq$!^AV_de{yzbd+~Llc9q-*nurLhGQ6cEj@4(`(fi*G^EUqT9~m4Fb8ht zz;FUbWJo`oiedGq7$STjLPKEhIdpY_W%kpY;dv$-3`56rX&7w7D*F>rdKUVBhMvK% z!#XU%9<0Ff7Ks^&1>AlfljVt2{Ikq*SdY;V82mXJ0{39>N%X`Em^?5Yry@9cA!h=^ z35F<017Hd^e}UBj*I&dcdouZwoFMGHn6ZZkFJ}Gkl4!n?GkFTv@GNHnQ@=)6!t|?{ zlrWTIQo<$Ng^gEJ;Zv#L*J%)}<~eh?{W=;1XTQU;gVx`pzICpAZ=^?H?M<90Y`>Wk zeHzR2Ev)|>iT+zD2)d*=Ew;#QKkXhFM8s_E}m64`B)B##HbdoDqye z|L5o-`~+5sd8V&2C+@->;`swq_$>TCaG`-tn1w+Yd>zYA#4x|Z?8g^j^I5F_8i^jl z@dd^PdZ8&y*TW=S{1X=rnEPk)Q9)(O5D_=v4(!4?+=NRwg|3@9bLfZZFVX|BvrCV` z9_+)wmzmr*mzh)~OwT66zj7&t?ipPS?O)|gp%d~nS-$V#N(g(<@l!MmhM@1?IWy?_ z2Fndr=k#2JA^Ij`4`birjA8pbOkUW7Jaks6{0BW>ehw}9Pi8yZJETQ$0cWtXtid#F!{Q@s$^ah1ZRmKUP1%LM zpR_3p7{1h|n0|&i<+PO*7YX-eHm*{va~Opgn1cJT551T3I9nKmGq?k-&tpiguqiRv zc$7_PK<7CQ1U+y8{m}HY)C*m3c@@WjF|SQ2!A)=3rgTV*5knZdo&&(_4ICiGnLsy; z_&8%Yg9R9V3=M-5xCeLPA)G_|&v8PJwJGbc4%5)&w<%Sad4i3nV^J@h!&EuI89bje zIZqeDENsI(9Ka&nh84IA8*l*!(DVWh^kmKq7M{w1;33?Av8PcER^S2-pexQ9LqFVy z30Pi|$dRx=-KJEa8#dqwj$rs1^u#T6Wyr=;uZUp=CSVaZUQ&5!(r<+bZwG`K|gf7mLY`+n1h?J1tSIK7A!#PFOiRr z1QqqgZ!}+VYw*TZh!NV)f8sWjTPKoorW=lPaFyRWrFv5AIQxyTR5e&oaf+_)Yt zpx#q8U;1z#iWecRXiI(Br_5KM@u0FO|6|pcY?!aI)kupQ+^qVBx6+0l;h34`YOTgzM^ke@f;Y;Zh5= zv#S!i9;d$kP3FsO9n#*%iKBe=UXHRw7$pp<_lRDa5vB-JdOgZd@Yyn9LcL~bzVy7C za6*{pyu>DYCV&&SsG_^YCb}VlYv4B2FTclpwOYByY%=F@`|9W3Y<{#k{sh*d`cRc~ z=AoO4E5OQA@3C9X+Gd19!fo~Dx0tUpC(m>Dq2BTd^A&34Ez~h09%xeUjf7ZN9vZ#K58q9$wi=@b;&Zxk=L7dYj$$Q>r=J~Md z?Qdh3=WS-|Mf=BTlZ6lK6zfye=fBN-?ZsY=SE@BKnZ^BNLcQ(dem#)ZFZW8a{3<2A?f@o?L5O-ht%6BaV|i*ND1%iEX36*f~(?~-7IWN zhH#)~KVKm|WEWkbZQ`UFxE_wBp>3j(U7Wbz4(k0p(yBNPXM|(+M%A0|GdrzBheTfW zcE;qK>tU1^x8QnhJXeazglEqNn&*TKMzk<13N#;S@Uw1B8{6 zFh(fe4XiF(S;Fc`xItJu3EPBqE#&#-PAgI4WapHS_Y+4o9T2vTLyO}Q&h%O8ZFTb% zR+1fUk64i9C}CHGqWx^*v{JZ%HX=Pe&SsktF0@cv1Vq-GkLs|wXtw7h3=*!NgmJ>S7K+zs zTaGYG7}ghCm9VCTU$t5;wRH%^8#ssh$cFjsnQcPx_H1#H`ry0GSK9VPVcuS4wcR$y zBU#M6iJIuIWI@6t?|~Bf-Xvj_cR1|*055DM!eCUryTO?@Nyk>{0qM*tJt19MrT0lU z^t8?TlN^W_lAwOe;w2mrHq-~iElY%Oop&yq>O1ZbFX4Ca%9h1V)VJQjO60lkO8R-? z%6t#IEj&A3X+BTA{|@uDwjHsHw>iMSmI<@{Dv&mM(iT4pny#rc=>n~96eGeTlBpr^c z|MDL54L9tOwsUu%7L}#!=K|t|@(%0WXYR6V6H`1gJ9dkDgUX$qkMst4lj1Oswj~LB zFBGpbFB4&vaLjG`SiR;V%Vp}_Z!w>FxM$$Qam5sy zIQK)GiM#G2C(7C{Axy7nJV?Ob${|x!o zDI+$~EEkS{36Y;Us*ijxH(&L?W%h=V3-#yTXTHvsB^iH(c7Mtg)m|;?r~xXXRiY1F z2e*gI2p72|-~&Tk@K?ndi+2gyt?VAEgbL|5+RCF?--JO!O-|e}RAxz(@zLQdx z7U|Tj>WA+#-+05A^gKs4(R}T;Q@`mHb9v6bCHs7rs}Ejex#67aEPwLydnb2WqSO4i z1&-U|q1hE^Iq$ywE|>P=n`BR5gSPSJL5Xui+tO>MOO*oq@^86_S4izL%0tVQ0%1#U|>l;M%yH`qcxoQ~di73-ulE z=Sn&x>3OTTnbYogcL?Kz0ae`bK4w9fAuNe{xKJ7I=-H(>&N061Z)@)e%mKo%`i8do z?DcVymA9=fstH3$b5xw8l4t*c`ZBWG>Ll}T7w?Y5+X{1^u&>_79`l6czLvBtNcw8x zGAJ_H99JZ=?zm1^AY2zOVs0x@^(Z=P zn-aE;L(7t|{|@!R4{!zZT+P6-OM5Tkv=YsUJlb24a*}YLP|P~9X(NF%z4P>yqk=n- z@-=XF@)^r^abC%7;zE*};1ciT&c|4I51ErP9^y76XZLWjlJnpOk_+O-l8fT@jve>N zVxFatOZDeI$Pg7thJT+AboA?UlW>!8rVrqduv8Zpxi_4%Tyf=;a84+CO>CmBeGdny ztMBZv-mTX#V^?V}>7|}Fhe=!2+dIsg6iKH@UaF=)X*QiJ5_{fta-|cqu7(Tava5T4 zLh*F<4Q-X$#)SKXA^ii4IidXz#3fL?p*v^2ma>FLOHI`0#)-$Tudb3I!YW}*yJ(tI zgah@VKVllj-^?^D5$~SlZV@`(EegNk2j(k&X_GKQ$XRQf=!yw0F1bBiN^*y|tig%4 z*{@@k<5K#^Qhvfdp;uekwkY9{FsdE=$}HiGP!trK=>HOK{%-ZQuK99vleArZ*RM6MA@ z>wEQXo{{#e_k5UxnI6q@eeco1EFQw%Dp!c~M9-Bb>3g4c`KFI8CBo$U^dmM&m)NyB z!%e~+EvNXT>X}nQQ;XFpI#s%|iWA1ix9l19=-gD;x+d1k<>?cRp^mS-X%W2JHEX(@8Wnp z_y`|>DvzN*-_PtmqkfEg1?~3xOaM9f5O>2DsrMf6FQ){0thxj;^$~H$|E{L_QR+>9 zZocID4crnJ7H!C#v_Z75%|1_$ms$0ePx5)gCZUTkDzdVP%j^Uf!!gc1^P?@wjWl&j zeXz$}j)!zdefUSY0ES48x5WD9hE2je;elwW zxWjt)$-Uc@ebHTxWlHi#^_srBw32ZTFb3J=YXzHxKC$jb;;F-ou4)asBeTO;{l0LniTlbxznKjOd~D z30yf&LLcECVP4-GB@CWBE}JDx9fy`G;l?@j-aqBdd6#sLUCbudv~5J_xQd64==JOo z7EVG_fJs4E(1vNvOX$D)V)eUK^SQMsLHsDl5foN|txnLqn&*IsE2`T{G$nGY@8WgV zHaQN@{}mr09Y@;74%YKWd9~1cIt-rVk?>9BDeYTqpMS7At>a1+7<8Z}h!xt34* z4+yIs9xkKa-ldI>C(_r1Rqd;g8-j%1Yj}`~7&vLd#o3o&-;Sc*#@lpTh4_#-t>4pk z2!q#(4%BWzM}$>E{bNwu9^rs6q~Ay@Phu?*PW7R26V|U2jS?S8T0(@Q>(u9eg7-)% z(k8EZ+b7J|mK`*zN?g0)WOfPH!c}p0(P}o~`nZbZM!2HnrntQ1_HkLsaY4RPNgJfN zAUnBACvm**3k z{!y?`FgU8x`cy8H6!PfT4=>>+VOL+u5yHshwa@e)!Q1Z)VSzBGe>znanF*cRiR#y* z*2=A>2=3AuC9HZgv4+!B`&=HinVX0cEF<8P5CXszRIsm((eCDcCP zbef5>Pf(x#Nxm;glFqQl*p%GL@rgMvsOPJMd9sK(EH-U^<8lV)!uN4m-0HiS@v4~q zHNd0hghlq6v^sdjhD%XGy>FAxR2l9`z(?&6b_fH8gU)eV>V2QmzeO`y>`!Os6VCG#N(tyJ~j{k)o`IF>DOmlpCC=&eHc>2q%j25DNcVzbf1Leu z_9rf|--XMw-~HrOx13y4)sHvKW?PM&0kKnGD_z0}VMx0}w~Pr>PgcKjFP{X>N$1!# z(7v9uSVL6(6!pqao3GSP*iSrv%E>iQv@DF9;Z~mtqzPS56)R7B)vXXl2&3v(Kh4{V z97(>qK8yi=Vjh+#!q-@^zWGc{(6$5N5T>f2)|qt}s21GQJ-AaTCdf zaof0~%aIt8#LA^f%d=gqltP#;4Dlc{!##HgH@Hch;R9kAM}%X-pdRiK&Im*L2L+~^ zxfBtKGZULw?Jk`1EU_|qH}fd<-Zz^qsy4O}cDTZ#X7N>pEkhU}9O^HOHNq`Iu7TVw z+q#6?C*g>2LfF^$&ImI%A7@??=1xM_vpF>_)J7#pSR&NFNPl&luu6DzITf9p!HIua z#psH-tmJCAB+fV(ZCu>oL|+VWQJhO3-A#xo~;O`EhBS zu?1nAXqB`57ixR&I`xSHf3YpTqJxcIDN}4>EQvbr-;eBp1Q?C6~mx zC6~k5C0D^MSr#V42{+2feMxq4yOP_)jU_k1ZAxwr*OS~Kt|dA9Pt$|AvavNDWJ$^x z#N{Lx#ib;d!o?()$Au)ff%BiZvNn;LNcV}$sNWVz?U4Y8cNnt|GYtt{}N8E-PHdP`HImNg4aNxa3B-h~%cY zb(~n1ViOm(eOwS1);>*K1pqt`=G-$-}->99Bgn~+na=yPMn{gpd9b&WrxSLoA9>Sdic zF^0wi`*0ncF<%Hb#Od{|E)a3#Kss<{<%|a|;`+yh^#j*%VnP}7wQ*gX$I!sR1?t-_ zuPPL`0AdrB?Bcei#w=FOcy^}e@j^^KuU1ko>%z@&#$NT~_Hf1qhFAHt_x?u*P9S%s z17|PbN-JkA2xmM<7bk|(c#KWl7S6%(wN12ig4~oc z?&0K#d3XV5kNxj_o|Q8mIEXV&R&D&FNHJTDtxl~n8eJYIX1~#G;6wwBu89*%%;Yj{Ajai1#ferQXQaSBPM!h!2#gIXhmm5ej54vRz}TSd1qzogP`17*YaEsiPE0!E z5Dax!J|)FJ8E%}NqER!PJUI_m`HYjv`T~7&8V~HoiLyo)z?}@~>iQo+%43?u$>W#9 ziSaX5Si#jK*T8Myj1BDKsyJg$Zu2gd8Cg zQsF5sD!F}J2xn}da*KW|Wb6s&${8E&!}(5?<@h0_=al504Cj(u2Ir7m5og624^qR4 zH{wRu#w{c_!0nYKxs9CSjAyrtn@Dbf+m_0jUdZ~E^0{#0BLrh({J8GQY5gy{Ka6Zi zg%h}j>)$RoLiexT=)z04E>V`ty1N zjb+_9xoiNpaZ~y9fg{L@bdV&jgfk9F4p)#|1(%at1DC-WCuVo$wE9HNKt=xdN_zT%lfH71xqn3)jRMPp)4^)+IT@RV6pYRdB|}?BfbjKIIpggg9fbI&oRa z`EVIr*;ru+nM5AFP>4$6xRg{lv&v^ITg1i4XLL1O7#E@*v5ETHIMJ9@FX-bxK&DTm zmT?;=8e{CPU0htMaDgjI`Aje31q)|vj0-2u*y#K(V*JHHj516{aVEwCCvXwTWpN?N zm2g4H)p35wb#PwcwCnGxYlcX-lyQu6;*1TP;jEH7z%BXs*w|=ml4U13H*VkHwEO=6 za?c>eI7M)~l1t)tB$vaDC0D_XB-g-gO0Jva`ah85CK-E@o8UT<+rzbR#-2IE)g@>D zC9d;0;|q}oSJT|8|HT;xkqs$h6ek}fg%bxcR+z^%CAWcVORkCQ;*J|Fj^9Jd2id}j zgZSAmHqpSHRbk^8&vD`)Mz_R?#u%OBm-YIL&WkHorGu;^IoxNSqjNu?cSDL>Hd4 znx%OGw^%vjK};#Gj+8a_stcD}9YdS{;vjxx7HKRT#-$~fz$GP@#l>;PgOqR)DPJ8I zI_-FLC^C3j@`DW;Bvm-Zd2q&_nc>`0z5|>?s?Yjj{mV6D1KqggGr0dVo^b$qz&Cfs zgG6u(obh9}But3Mn<^>8*R)vjoVQ=M(XLpF( z)a1=L`Tng8dTprh!4zh6p*VNtV_y4_BMq^{PaN>Jd zW2<*o&X{kGt4fVo;!2Wpyp(%`_z$GMC z#l>(Qjwd#8+*TQxlrr{laVg^nCl2BfCD}ybDK0CO-N&V+d`g=2F6DFL#L1P7t@a_s zK}6+Z6AY#G1{!-NjuU5MEStfJFVl_rinw*Dz8Wrg;`IB!HZpJ`wF3`ue#vd)d^qEQ zcX1w^@xTk5Qz~nE87Cx_b-k?2g+a>bC!9KR0q96%OFK zhK%AM5nNkxNn8`>7A;{D`Esj#MpwB&eT|oM|0h@2T~%m2lTBQWgBZJYf~(?;`Sx%n zsey;Mg5>PKLL;@ZjKA1K#U5lDX>4^6mz7);mzOF`t@0U%B)_W9*ys&h%2-w$zlls3 zC9a2y;f!aug$qk=2N%Q{4>HFEr23XPpX3}F?!P=zVK34xxpkaNaxt7!a%r4Has`}S za#fsFcY6Pe*0+!*DPtd}NN$8%@@GlLGoIoOCAW_|keu=gmfb1G@tw$hr12ikx5{XA zA)Kht=;AnWVB@%FR%MN@h?`4gYqq_|!aBaz1U&+dQ8ux$3!fs?u${4_HNC%1Fic-ELE-&TF;WCn|;F3p9 z^uOr-1~Mj9*u_PTGOqG%;?|{n6P);KKjVq);k;75L!4X6SGH$qfFwOQlTIG3 z|DyXHw=x-!R?=b<&Wo!_ZXH*VTntw@u23(V#^oeez==QOG@fv^j7&+gg-hU!jp^f} zk{jVdlAGcJlH135C8zuvOCERX{?CbYNEv-N6VBM`5N^ret{Pn&cYrfCI)j@_`HHyN zNk0AluZG-_GPZGJ$qjHL$!+5{CAW(kNN$1a;fxJ5y^8T~OBr3SVlLo}Z#eubXFTID zt}f+ET)<^l&RDjDtBT`l*WcBIt0OB?V>-B^bdcf787my)@>0Ip${F(=;If8eX!n2C z9CtoaVfO`GVC9SzMsP`~Y;xs{`Et0pl&_NG{!d6_#s(RqlI!BalH0_sOKyS-;EY4J zw{mu|uGz%WKE(O7va9|V(*A1h0i_B(IG5ytIEQqQ=*k%nlEPV~vU!|hbmI6M$V0=Q zn2W|VaSO@yaK=AH7x}hu;!n|yXS{O(H(xnp1DCk+)Nl|{iQ_h2^KnP7?zio z;=_a^_LbPjA45N6epIMrzd9VhMES>^FZ|P2z8rbj#iwGevDRPbl5FUDKb5#~nWw5Z ze2?dh#7KKzVB&A3#RH$N$`Zy1k1iKJvX^jS+|sGud!yy58$^@4I7h~$|KO)|@>4<& z`$p``Wz_q>$FKP3qyw)o@t4oqQzcA!y6+Y0CEw?#03Ol>cGbj>l&-LZ31_cRpZ9(9 zwP&)VORqHXFNCNs;aO+pDseq)I<;V0xT@s(xQgUPxT55yxIE55YsDtGkIPBEb*%LT4qQb4MaGnHoiM2XUgMB3 zMyUS=!sdLf9{Tm4)CUQZ+FtFK4e_Iprie|PNCubIs@MMNzCu{l>#Oq@;>VG;b9gFT z+tEYy1)nruc5XqiK|y2FO$AOF=hc7f;UO#$^3*i(xPmZYhp?ozMIWjJ&i*Qsc+!UW zAxVKSMyUTN?0lUtMQEIY9b6K3wD`n14Ohi7`p>|32peP(yTm3q$2D>M=!Yd-HvI;# z_=Z}<5nQ-7?&yq!^W*B03*#y{W2+Ll63$5(v59@z3%HW*PJgvsM;0k;ya0D_dC3iN zS=`a7i-U}DqKk}WXSjrv?*JD)cD%HR!q(TZP^7|cT<|!fo-cs&N%0{XDcZxhn02L0mqsK7Yv%{ZpjJ>@R42_RJ!o_jZ~ivKyb(Lns>4 zWPgMGN7ac_=;5ZgzV`f($BzlKui?7MF0qMXGh7uX(qa?E4saWa2Fs4t4KB4C~#Os9i z)2#{NfRN|T@X(cW3$gcgC!^@1KGW}Te4JmuczFm#mVludL0kyuGq@-&F1ZvggA>(? zO)!tk;sBexYbJ7BkU3CkK#CIDzU~0 zv!Wer9{zLToWFVMwfbox+%B#vG8vv7Ax1IDzG(5}0wLyA4j000iifOSZD|lrit5LH z%rx61o%wBT#qe{~^SgvKLJy_HCTd*Znz*CRb>mFG%MjrMTs1hwDnpJ@{o2~8KN@BAQlcw5A&-(9^(>QDQep*B!E z`U5dcuV<~iUcKrf%e8BM()F@Q{cg|vC|itRm!I*pv}tiZOPH#hzA%<>3Ea^IRGdv6 z7sc_LRPocnK4F2dsea&{++t1$_bckRF0$Nsj<;aSW{aC1N{LO3sS`KH`SjQL0O0{4 z57YP&zoIAF;L%|`%Hv1eO%Ux8<+T_0n`(r9{^B?)s>+}Ij#bp!WnYqg+SNsR{K<%Y zmAloiTx_}AG9%r)Tm9k1mK$x33N85{_Y>+h{MOkPBn)@NeUo^8^SL--mT;Zg#U_rD zsW4hN{Q_mH5H1M)+9SxWCsO{1n-?H{8g2ZU?T~#|_606dAOAM<|dWo6Q_5L0w z@X^zAcOB=#rS;$Orw9{-9&sQx5C7$HY1~YG;7@q|29f(?CLZ15bhI($tX6l zqPB6akDq?s++8_wHewSkU*O#2<5>veiQ$en(gB3K+VEO}gtfl*J*R{r^2% zL@hb?P1)DgudX%1^gUwrY2DEy>=BOi*}F}c_)`-Pb`-at*Ukyuf41t^lWx_fglU6{ z{!G1U&2p{HPdYjc3bHVI9` z|J`cQ2Ltx`hwA-nmdjr}A)O)Lh1SVF`|AInebzUzTG^+))*D*yp?)v>?0+E^x%TXf zC}D+=2gK8|>$8Ld!Vo!?^zn4kMrDJ2JM80GI$}O`2;+Y#E?45w@|F={@h{b{SS^>^ zW~6KE(jLrm)?#`yFX?}&US(sKhjjXrCVEGEYS70nv0QRKjthKhbx@8grb-5v#EH8g zv55gK;_|q@K7vg`L9Y3`8yWs`uM0SJ|edhwS1>KJQ#TaPtr~N2SEmNYAQ) zGZvG@^A;-JQlEata^sb8(zVga)n1H62G<>_uRp`}rb2paq`vnIquwPw`x|j&?QQyq z(DAqGSIB0Yk&gbY=-#7;o?c?JSLxKh;{vpLHWlH7FwPOgCQdPgvu~StcF}RSoXg?z ze}B3~69SxqBap1SurA;ga?HD>{t9YeU~u#S+R=5qoOU!TRFs7z0ywqcu6Nd zC*H$~=b>1_gvHOPzs#<48PfIpPhLAjKNN9QTtv^_Anf0-eoWLfBpv(w>LvE9y0G&E zwi#}Q+tF@N%VOy4Z=;3@y-!-cjT1-kvwy_?*hK_}r%{Nh5M|%N16+EmM;fSp3)4eN>L%6AZielR)Oz()y*W*ikg zbH>YII{4hT)O#Ocx%R3t>Ai39QiKL2L)q~TR?BxzccvFN!`Zb` zdZ(}BrZ}g;#a7OkFO8d!PYjgU1Pi#a) zoC{mH9jU^dRX*cE=D3lRZ;9K)IcU4q|03ad7ZXg%=*9IVw~p&dE{1F4jFT|Ea>m9K zaLv>C$~@;(m!c736P2`Z4KiwDY*=Q4l|RZWxQ(kz4cf(3CAYv;Bxm{q&c7&0*B|iG zD>*+dE4eT(Ex80Pi8IdjEG{Ow5-u#9KL7O`@Qu-|oFzukc6;x^XV)zyX|HauJ*&xg_p@*BIl0bGUt+v1cmpX8z42 z*&ySNgW zeET@pDJPEq9%l6^$wdgKNX~~l;BLxzCL!Fu$r&II=FSo4RQX{74mMJamP71DY1!@o2{I&7Y=alZ}GXAF{8C97{S@4vPqmtayi_RJ9q84qW?t;D=TTNuz@=`%DBqcy@1dWpiU#-2M`BCCUiML1ECQcxXTTs+^QVHC?eW~mLt|R5MzKD>-x)u9 z;;7!Y)%(x!!D)rG=Rw9t``lC1>Ludev|wb;4hh%APQ5zqU}Ie5L6i26McC#9NiwXS zNpET~YJ_~*EGhrdZn%vU2R6>30j?;y zZJanSV<+w6GC1Qw7Pz#O&vZ9y^SC}POrn4b89SD`^W!3t3*$nPOW?$a85^C&`Eka^ zlyLGW*2_pSjK(wR;2bz(w+=7h#w%wmJHwfz`VMf1OiN=v>-)JR;9g;Dp&L0z8qXwv zn@KK$n@TQ;o8XQbE#^sXpvjLl!X`TPs)8AL_hGDEe3b62-PlSR;(;SKdRy zAz?dN`H@Uob`uky9x7z{F|0q0}023>-xzh2{lKJt{*C6#$~y$&SVtK| z)8|ql;g))#c_HW5Z%N`vD&;`Y0qeQ8;~p!i7~~g^F=PWFnzn=d-5A4k6ednEb zX5M+{dd?y`7g3u=RL`lvsEw3XU4ZBUM9by(mlh#vxK4+6bQTv|j%X61HUU%})xhF` z+1J}~U{Q8_0i-3cuIw1{xD34lTk-!8|f+XxTu;81RM#_-XtDAtO z0-Gss_WN#x4@DHea!C`CC`6ATnk2n}x_>I7x~7g5kO54v!>BNMz?zUu`eh||eX+t4 zEcxw8yY3;S)CSWGyX2U2qml6~enDS=v5Jb&VRQi?IctjHrO_xu2 zW+GaEXuSN=aPu0ClTSXdGGI27CZVfjOnO1QwgQpzCMW(J-WDHs7?BEa>+IBO0Ja&J zeG9YzTLp~NrVmLqSTQY!AbRb5P+vrdA&RIDM~(@g?E)kHoP&NYB6VO?evGUD(U4|p zBk80r1gXma%>-1#zY1kR`s0W?wMeI0^n1}lDX54l*Drk$%}3OxEGZTSYymK=N$6Kn z5)myylt}cU?=)bWf#FwDLYR?+)F5W71?rszz$$^sR~AQ~EMP?r zSU#|24%jMSd3G4pLK(1ZVAYhD{Bm^Or$wWWs{tY8ZhNg#=5@eKz-*O7SR*hOU=*eg z3Ds@J-(4qec@@7#5R8Zs4AOO4_eo|YO3h3kN|y|b^0LXG?{r|n4p=s@0ARM7rMwD& z8Gy;xp}KEYSnO@TQo&5}&w?CB)E6mC(t*9c2~jFkfZYqVL$;s*f(9cxInVwHh^8TG z%a`;^1(sxoQ7dNvGXul>-su8s0ip{K4UmW3&4^}8QR!zwsu9gW)GVLeHXvGz=uG*3 ztZpkD2BP-m@KrE*Hqbv3(F=Agq*bDV*))|59oTX3$qiu1R-!f*N>%`@1}rWJ(}%DP zz^WXu3Shf{*{7`mwi%ecQPcsW!r1dQ0xJ+S9j}J#K0)6FqkYD{z^GzuMJHv#6wID4 z9vD@xJ(dD21DL%sbAiol!z^;?asZ|{BrXC*O-hwUAIi7{Sh54QOG#^Q!nMHWg3n&* z(+Xy49edybz4E6-fo-qIzDPzwU4lEIvy@Ck}pFNfejFh&onG7XuhX=Xx z0LD8oE(aC`%wCzzz``7`N?^ebX%7SQb>M3NW&oztOn_9P76qhULm$%KunkiSLBppG zU>@4Oi1>;czHk7;-8Mu*rfKLJLmV!os&9~i;!Z#m2-P5* zwo6fh6f6&fnNRa4!kHhx3{LOOB;x6|@=EuGDkY4&hO@wa4G33(Zh(sPq3;%8m3CM# z;h&+eNrD4eB*JxaCY{Zor6JxpOGEd62Qe>x6>{|(fThmXVC5rW5rbHurUBS;K4lP# z?%%f@BNNE>c47v092jLspD_F-1FN2+pEa)NALRb|#2+Li z9(+yU%i&uhnO`60q&8}NC#s@s(9M~ew)s5CE&!IC$^DRk*GAwrPX&PNO#WL0yU+VH z!j(v1Gciig1gtufdkp!3>m#f)e;FehM@az#Edm<6&0z;fpC*9NoU`dtX` z0v$hZlfruOX9hDDsajfqH_Rh#0=@=_nSs&LSEYNJMfctvBT|TvH2r%>ilp2bcYFfIs zm;hQlXdy3ZgbX!BTq}9T5a#c6*~Pz-n=_fQtMph>0hkiAH3EKj3>Qko_nD_y!4C~# zJ-VX9r6tQxP5nZTO*yAKT!Zl(pLka@CUs`~fH(gqiuKSZ zQvwh#kQ?*$szP~{r{R7%CilZ-;7D7}#|&fr=-0J`D#V+XV<@L=A&#$gW=3z-@qedm z0cX_*{6rM%Cq3^Jz6&_Hc1DY!dL`N_(BePS2rUjYBjOdH1(&yJ8lY-%mD1Gi5~2mec8$DtbP#v}i5fvW zEekZ*GD}8W_Z5`ep%K#5e9U8bSd7|!NQ#SphC|Cct1rWv9Z?$6QI(KUwG#zbbJu5a z7xduMj17m395J=se14a#S{(^1|2IxT@K*^zrms*4ClVCQ$C%jwSIQyyYb@p}HA0v~ zkaHOJf8AUe z^B!@`ec~=eFCSN$8D#1kwZQ8BXm^{0ody>5XS*oEnt+AdVKgSVRAQD_r{QB`S$7^5 z%ba||WkjGpOdsM(sKl5jbM??>D3KRpS&yfdBNBeH<6L($uwY>J6R=8PzIGTD?J%$= zU}l72>S4B3Gv#=>68!ZVOyuxM<#%J5x7M@=n$&B!e;o68CLEDSFxb=#178BL5MawG zQHs?0!_fJi-ed3URy%*_qDpP=wq`k<5o`WCNILE@`mroAwNB(QgDBI-mtu@jqC#iD1Bo zTHLS~J?0AdR1zdjLxO>|T*tIW#kY=vhgM(UTSqW&!(8CUZfb<=&ftxAE#_e*EI`v4 zzFy7utYV&BD!`!AFrkDph<7az@e{7h=;;Iv3)(DPGH`UB0-p2`GwO9!m=1Pkv;#d4 z+YI!)aeIA8cQGTuvFkrM(!p{0KRK3zBiQx7a;XMK%71b+fn(W!a)j(d2meow6mT^D zCr3UwLfrmK(+Y5;{wGHRI12ug!}KlO?tgM5fTQXEku42G(A~{fb?3QL9@^G^Z>GB6LA&gdU z>M9W~#n%0~i?gay8FS!*RmFB;+Z+IRI>CdF9Q}pd^jxPlCej*z7mz{9FeWr1OrLoA zR}D`)oCyhPn%zkwkXEeWuA{K4oj|l0hF`DlKZ=>eGCpb)^NS%rund&6nM|I$B(^&d zXH}VtzaidLpkl?_f^trkNE1JYVb5gCDE44aQb_kbGI$m@yp>L$0L&Ly&U1>AJaaSi zidg38L@@KS_bL#Agy~4wHv`p&=>|G@8qZwIOo8eqXE1jd^o2$=ObNJs7c!wm&rghJ zfnqxUDv|Z(0g23&e?6LoQpor7veC?2A;TxpShVI9Lt`HivJOd#%)jpI254;skBTY zn@j^O3AAi7rc~EjwY&^jB`-r%bvz@98G9CiYx$>49^LJRKPqM!XqBHadUgQAp8@sN zKKwbWlK86F^2%{6fRzJn ztl)o*V*~UT5Ki04=&S^?ncOb1rH z```6IEs+OW%3i)>0*e?@N#QCuGo_1C%^wCpEUfLfmIPkStYx6Tt=$Q_Jgl;`hUdimXOhs7qSi7l<@{+ z5?sw}J6e)56%H7+M-8yDA9%kM7CKC_w!@hD?EiO}Nv1Do`Jhoc`jCl)0owo!XIV7I z8UH97)o(el;+l>Xu^HHMJFG9#Rst)i;XgdeLfK`6OKSM7M_FKh(-F*!!E2;U@S(i> zDwwTulYp6l9S5JlA9;-Vi}Nad(gAJ&!Uto;SpUAv|^FdgC zxLp_}Tm@_yumWoD1Z8zPNSy{JWWTTlk+MDp?z$uG62gI90CpHyzP!8AH3d5|!M~t^ zk5a?+^G-oH>nI!#c@=tB=!J&4Zb{m65ENi)r^*b7-K!gYBhglF7u7V5Si09tyLCoCTRuPVKs-8 zsrXC$72WX+9FvNFF@brjomF%BXOFWXJvW1p^>=%%lbfsrmJX~yiwH&o70(>X{KO<4 zHktWaTz-QCx{Njfkv`OR0l>-tjaU!rZ2P_R(szcf= zU`Y;q`M?r^$xh26mt+;d_%=|+%78^VU{%1v9k64-`Z{12fCU31M?oJ+dCdDA^DtTf zI%X0Aj54vWg(zSl4w)nY3vj?@0y8;a3xF9M-2FO#m+gv`^d@*y(mgsS3@&>KyozfgN_h(t%Ywq|F9a1Ym^L2PVpD(~l2gWd9 zl-M@JQfBeMb~*5+C~58a<^rp5NSgz!%np+#Nksrl>_Ez-1XwXJ`zF~1tjGbY1(xrS z_B5~@2doL$0td{c4)>ii9Ke7&cz6da99WtImH;fp0ZRpz8WhVCNZ_UIb+mWYHDi7}cBx*Jd;pI};kEcD!oTlXiDE83G z?>9njWW=k0m3X%M3vxPT#0M-MVPS6jh6(Z4fL0Bf6VdSLi@!Qx6~NN?+Eg5J|N0~z z^EaHqL7XQa`V{)#oH@mFG5x1rPwX~1iM zC#a;X!#bR7(m7iD27zRh1>iqDkWZh@?t5q#!n?w?ZRdL-5K#*(Z;)1~M3{Pr?PZtF zstp}_9r3n+*A&4ILe?W8=a4P}<{<0Qo=z>G>1@jc@{?FOgSR45E3fJP)sdW4DLiHl zbJypC%``-d?YJp(u(?;w|2KyPcBuhsjK)BzkrXs1@sr`Ozg=@!kaIjB(|Oot96vXQ z#dc0ZxN;`c!os`lKKhaEo0F;ksW;qs{1dAF*O(gDlnou6g* zsS5PG|8nN(N9^W{u-(b+Zl3BnS;F{i@OtMU9Q;JPr%VOC2w3D3y!2W2xTO){+G*(8 zRAlTicC?3T>@>EY4^V!=myq9dt<`GKH`-xE+u$qp16NM}V)R8oN zD3g3(l^q%Jz*HUS;FL5Fp%R60yb zg(mR25HCJdVk5B2z~)Mb-0C5~5}wuyd9s%fDaCRB0nd1sId#qgXU-fn zvL}-B%r{ZoY!A2$6FD^f`9Vhs@Nnn}#27I2PXaK%v0&4=cO76=6@zN&0PFXNVf`D z7O*V&h&SGT2TbY{HDFlq7WuYqIIR*F@NefaKc>5iIxFPo=D}SjAzbzj#LAZ`=OVfp zQKKBqM|14PwF9VcRSD ziRamHmX2`phdl6q?7jg-2p4~-rS2eos6I-7Wv_0}gnH&KU>U&7(%|mXU2Sk2+@&c| z6WFrW@K6882C%Rj@N#STIb_395#GFpcYA@|=bVpl#Trb>)ZA|YUZ!(t1k**)NR?rK z|3Myf)tjlG{0o=L*Z?_6>$TV}KlwcK=T$E-lRo4ojCF%nsOHnsF~BsxzEboD}2{4bEIrUmJDm9)E!Bw&Un>VbtBsNEL!CmZ<&bK=B4ow7S=tu+=*^Kr76egxGMNNd>u=` zeKx9sRg~};f6am`qg4nSer?Z;=*NJ${7Q5`UT48_O=yKT|COIZ0!s+ODZjNR2uFe_ zq64du&9v<}5Gx(H@GxArm6tbp?Gb4wEeq)yepgn)s+{&!1!Pit7J)wVIOgdHD{aM> zX}S{U3bxB2-J-B2-HbfRGae1a>Y#IO_ydC66jy!Q@{qV%@tp0?$9$J{d>@StrWa zY3X7)@4T4BvAMu>_588LY^Y^3!j<*yX-N4>1=Ar+AHog;D+ixF)&Q);0c!!a%0Zq% zC1bvlG+ACSFr@WsoAk$n7zwcD4CadD#_msWg|}OZ>*;a1tOv^h&830Q&c$qP1Hy9~ z_*=Oc{TmR@KFeWUu`T1q6`zRxTEC2;yqGH-U z6kaAXw&8>o1Ix4H?F+0NSk76#IuA?v(+D@6^E|*N8&o1@8A68ycI1aOI`; zl9ICdz{(x4RlrJsQFYTN0Don`j$Ps_UPIL$MtIdf{A+|`n-EU8+)-&4CS$hBqVE7; z@!+#zW?}5;i<`0m7y$SStXh5AiJnmI%zg(-e~&JB(VZ99Yy9zIqADdmLf& z75?oK7V&^d3q7uNOy3un*$$)p%)lbAX?dS~Ho|!sBFk@Tg=5ZCi@|(#KJyDb1~6Eq zle?wrGR)5I20v+lzZUR>tNCB~>{C{*6NP0?eD&+tPijH9R?Gi-oeg!4(u+chUME!P zW#MDq!2Vr6z}l`l;joIpApXG{n8DQoO!eSjya6LII*USbH-7gG_7F=$IK-2WT8i*y zgg2P@`xI_MxYCQ)FJ(g?kL(0<_tv$YqEnwr1hyQQ)TR#l6MZh|yZ#S48SpaD>%95@ zmcf8`A#C#DA1{M$w17|b;pdiNODF{4nLavfgsjcOOtd%Fg8{Q{$lk!$2ebVs2%-in z+klsG;#;>glTUq%-S3-($V~h-eT0|WV))Qa&Ai)6AG#t#m-NL|ynj6NG)hmoR)8~S z6JAl<8JvSN`R+GaFIA168$M)4_e7El?`hhJ=L|g&4pJC&?!;hnqbpJYwRkpuAc&JQ z`5GwDGhK}*20+j~kY&r5u8iC5n>wrT>hiZ(uTH)iTm*&l(ap%>54_7lG`^Tj7t`Gx z-%iO|jgUnO_-FA?-iBLx^KItlPIaD+6u~3bZ6CWA(3Ai~r0sWhJ4xqt<_1v|k7 zb;09(M2Viy$G*dQO0}K}LIntQL~x(a-(JDG4@`AX6nDtlt|ZVPoDAm8NSM$S_k>CD zKj-n?AF=@ccLBtPgX#o(XmD5Z`(z&F*V`?MjKruECU+hcM&`y7BEl*!JSIX)v_ckf zZw8p>qg4JtxiF#F+qSEE@&l*Amg0-&2&i>~=GzPb_np=CGuNy7r-UH8A*yT>;+y%CHlxr1fo~zV2ZuE9GO8o|h z`S>k#4bI9yUNfJ6`5x=Z)N&*P(4ut9FL5wm=WQcd&cQNJGHRt&H&9thfu z+;v~h0rEDyZY48%7%?myTLnKv z^*vbe=loDSo?8lB#k#qrfWE5?52Fx$&hz9a1J&v#kq5*ulW7B}!FaYNi&Tw!-e!Nb z@-=w$>RS04ylWE}=~0x!USRa+U%$yhR0Xcm7~_EzhHxBr@UsFzwr=49{@jNw#;q1$#ROcABi;UfiCke15zl9S$UNOlZfJ)`<*UNF zy)Vfw*>5%Tb4vw9HxZY`sNO%3DBbu6@3GGO+10G8TQSJTAIDHlLN_Wx1NgSp7!_JT zTb8QOCU>BDq!>_9Q*o!8tYOAWN-4%Y#=KQ(7d~qZ>(jR!EWy*5Fqc^T6&9QpEOWePhdAJ8d@h}j|aT7X{2oFmHFOL*}Wlu4ooWNY0 z3X)-`6*DKdJkTT4abb|?DB3UUFlFP#tjE-2pycEBa}80Rd)d~G|MnxQ5}Ok`B5%Oa zb8Q_xU?S8D>52g&Q-10}H{)gUzdktY8Mv19bXx#g!h9xFlfiC!ncucz8utcV#o#JX z47eOkoj^A>N7e#$6F^(l^i&`4zWBQUzDV#LSQd5M6k_zxD|7ATqM_&)L^?SJNMeLnk!4e2mH>&QnWx^vCRe zck=utAmnXS2>WG%ml=cx5X!eO>3B@{*%HMLr4D^#13GlyZeS|IeP_ysFX28Pu_10* z00VG|S*nB=mhc%LA@4GP)c~ESCyZUf*L;LNCt#MC{h54QRu{B{|3hFBz@&0TnVYYX zmi^rd0G{5?uo^^i^%8lcZ)hLsPlFy`i7pL&bd#6JQ^2zyV+u&c@HL@~-zeQFdI|5e zk@a_{!7&Yl5Znxw8pC}FpRf_Ct^$}2FpCQH_iOy^jck}(13ukqgjdWFLQ z?fMqIpTLE$*_yjdK1C6Oc~S}Ub;~C=h3mTcq}#)<@r5NQP&L3LfQgjU^ELiu2@4Q6 zNH@uO*-G>;gExlA9~2Y(=T)w@vi=@P0ON5Zw~9pVepP9YD|0aCpJIh^6@cz^5Sx)i zfA>|MYlVSS155=d&9djbD)pb9)gAB6gFk`N_@L;AluYAZrS3G$Eg7KUXT`u^OOn5z zS;~xVMWEz?a*UGxnMW%MAHJ>>hH)5_)QMW@)~ase68>E&48zwK9uJ_L(-NAA_jXGL zXim`zVPqJim+*%-ANgn$HpRy80!a91Y>Ki3j7*t9pAGB74RBAxrO<7EEyvd zMLP-oQP;_hYN50)NGL4iUAD65&QDUJy&@7hS+DUaTj6(8_>1&4mcO@^J)ob1g5E^| zZ}0`7Bo!%i&!)iq`+jM zs7_0jpJ07273GhkO4C{Is8xmd!TQ1x$Hk&B@R{uRmhx92!nG5mOS$C=g(2hkj%|29 zK@i%2a<5mrm)_wgx3R(Ze}l25K1disCCq?QAB`7q94Q!WO_zpBsaG3IPa(-&KJrr* zEl%LCe~KA$-e;^4XQD5bvw`C8d`mfdK-F^+KT(cne>UO$OUzUC*kta#9T%A^_>k>v ztoRCleLF@HmB@eE&H{%{K1a$3&1Al5%R3papQW-2&A=8bR3ZQt7C=5EH zr~z8@4^RP~!q}+x2cNO@S;_|Xjcg04m)=<}tF!jtp^Rd!aA+O6;A|uEQwyD>a*l$4 zr{3kxpR-WO)~>6aN^h#wrOiC%bLRPY2w5a#OD32r6n>A!fzdVPW3#SghQ&&awn*i< zR3&=uPYpozA%0TrQ5uM{r67J&Z7RKj>iI_Hx!=Y+9(eDGe8=Z(sOz-dF@`#|zVsUU zbEAph{hU23&gaimu%Mouk+p!GEZq4nEUFg@cdIRm=2K8M@AAXV z4I2e322$f`6!aASPPU5H-ryno?tT4XzGNrsDURb?cCrWcM_OC?orUq%$^6PrJae-f z^<9^X9;Fqx)c+%yQ(*%PbrDR7*uH&~`}Ng!j~s65x0ChOv%eF=M-Qy zhVTA}XCjX$(HC!`(%+|akmX9U_KFq=bF!)lgmt))Fm2BGN(fUuEEN3#9M+ZmtRl=0 z@n<1yFNB@U-4j)x`%TnYe{*v$qY#WNYGDY7l=(J0L5CDUpDKc8K+r`9%8>;ncTwb! z3EY?<%ju=aSz{+>w<4!ll4HpkKMsP(SP9eQuF4X7X z@Jjb32t|`5`9kuS&}|RO*r1ka{bBn<(d8?UXTF@LB?@!H!syMyM7j90X0tmKP~M>u4hXP5Dh^xN3x zKeZ(!U7$b4vQZi2G@# zh#{`@yjASrHTNN@&1-VEj$X42_4s+yZd`s85AZH~Sb)>~7{jv92kl{zVvjj72J-SW zI>*kAF$_f`%TqunYs+&QYxM;FN;R8j$r*DfgLqWwF^07)XZ+6J#Uc$2cuc-Nd4^K`~Z;cj3xlQMdGcm2Q86vyeq7U4-%%XYVPgQ+t-4#E4UmA(KX)x03}dZBf8_wRdTuNhlgMp1<-vuHHS5 zM83{)A_n?mD3c5NiwNf;yb#}iVr-7Y->Nv_(DA#i+v8E#r;#pIC#P#dw~htx^GG)b z{Ba0BgYQE~6RMnSBXjtr^RCLtX)co4$l@Vy;rT$Oa$YB?R%me=GM!);7za3Q|@rDyk))F6F5 zCJ-Y{7R`@#~ifvRN#e6k`zT1Zn0(3hhZ%;{5qU}3d9}}->sNb=&OhkHPF(vP^9Y)qpZn9& z+NDwg=hrt}Js-dGs`Ht>SI^)5{%UFOTUVE!xx}NtW4(uxKN^h+qE(j)tFJo?zbP4> zLVo6J>orsWb@3?h#1fCLoo7%AU-uo0Pz43>-@aqs!6Q(b;vnJBA8-RS#iUsZEl{Ww zCr8H^0x%EbIXTWo-nW_=`Z{BFM>AP$l?+J>6bc`p(md!meX{)d&z9vB}f(-PmY%C4bo znV|1f=y&fy&x74Ge<51qdNkH#XYS|(dtyLJ(w}?i5ytq?$ z(VyQ}#bSrN>`+zqsTZIz(=b`S)`^DNZ%Qy-^(2#g58pog#VY3Gl?``HOr|k!U3n6(iAb z(7sg?EgJp#tsn4`1&h5)?Tn`0>5YL}Ds_zYOj1XeOQz5a`b~xYhXef%GU)Fk6#b*n zk2uh;gFX-RzZLp{Bw=6&pMDVxpMl{|g<+qAfHR<<_u-=s;<2JX5DxO?vnhN8;mN-I z4GJGX_+4LKO5uG7@Au_DQ1~l^)!q3ygzw*u@UZSe(MBboH<6EE77ppMr&jAVu+wbk z>eANMK?y?B=TGxPKVpk#qx|)!{1w2v)B>C)e|;c-9g@HFd>heXwNMmB?omT!`694)q=GP;|fp4N6 zpEvkE=baDZwS^y(bSztL;agr0iKU+zFdf45hQ>bZm?WRwWVSBDK#+NH+~1(=!`$2jR&?#N%uI?r3=FL zBw#cpcf>NhMZ7CI+d{fFT|-v zhk{1=MNJ|CaUsZkSI(M_-CSAh^oZ!lB5k zWQLn?=pnSh4(tq4h2s&TUzm@}ITYAtm%Qi~7GhbVR8#R@;m~JsvX`T@C5yQ3}WEyo_|)gN9ivQHAFaCPQ71 z(!)?K+h0YosA_%%k?NJIh%EIAiZNKJwrpVJzA0gzQf)5*qiP$1?hCOk*jhKHeaB(h82-~S*41*L3uLRYTv#s@c0(n64w;UH*abP;gQBg= zpyv*RNtfs3LTo2{p)!1=lwm#6QyIuLsFgA#09&i1OH#`45HOU%V$>keABDgVe-ze0 z(c(Da(6QU-2-xClz!qNt5>g>ZF!i=rDnzc_?!O=dYWK~c(Gsi zN17{0^O{|ny-J!-l{C0PqwF55v4nB5-r0*qWY!-nQT5s_{>>lkzM&bAU|TcCqn__9 z{L0!gQC{rOQDv*ve5e(59J1%t@x7g5qL@zp0-U!%EMmyq8kp+PL3H(@M40SUth?dRNKfPA{$#zTxEstn8!>C?0SArMLa5=ID4j*o z&orm1v#yRU7xvT3Sl-Nd)lu?gZA zTs+C#hizAcP-5DkqgBtRIy#3ELfbK8=@wwEDq+q^Yzq*+81_`c$3cYnJ%92f>*G|a z#v5*zpJdsp@rk@!Jq{5w_@H_=(0c}wR^HVPZon8-fl#fhB_zf!$@VZDudyUd^ei#@1$2^~I(Z~qHNyCe9?znDe!(=k5%Z#KN! z$D@QI+!f<=Vz#|rIE01&_J}cj$KNb=%u&$j;58TY?SVq!(LmwQ|3D9f7Izlzm8vyB zS_+V_mv=?yAq^epMC*hri&0r6(wnWk z;FQlGdX3m=&bf_iynBmpcz=(FTXZLdLONfh)3OpLA>zi}n4e8Jg`fc;Xt3EpeeSx- zv{E*uZl%B8HLd*E6{8zu6~j(#_*%a2JnQBC1vWmT)gI>;6eNb3W~M zYw|vxd65N$2K6%I(A8Mlt+U5|DxqzAAPgllp=33vYU((B@!rgf>@o97#C@S>%X9K6 z+dTNZ5EN@OMAP>sd^bQLE527E`#QBwD$lwgv*7`hg=bvC6PI%@G2bq@(QG7JZh202 zfPeE3F0r7%Zy`O}$$W*@P#9~HVM6<>;T~}R%Se0Vw~z7@m)LMs=qT>@4_kM?h-%mX zuY3)MzB;tmG_>1k4E#A$bt`W{+YdpL_tt%I8ROzVY_!n{@G06;0;x6ExloTd$NMI* zXuZsw#4P^gW!5Wjyn`a?Y{LHJHbrn4bChqr%<9EAdEpi2*?kFw^oPJRm^In*TMX

&%o1EWDoz2$Bw=v{WBfpOWIU0J0!g}-a&9?$zM^n{K z=jl*BVPM`0i=aE7s}_=dP-Oj?mww)@@;?8$%4a1VmE6EYEV8q1Ke^E6#W^c*%L3ld zq=gZBm5#v!GgonpumB45zTC>GUdfE=anc`y_({|!ozUGz9p0Guko6qmG+b*FXpEOF z(Wd0n|A&${pyXspNgqi`lS{Xye?q}VC^G%p%Rk>#ix%lX$19h#Gvdr&BPrM8l30}Q z>a%pctEZHJ#*}8PlpEIzRx6C*Hk3IHJCA^(bi?Q*940AF7edAZ`_w`CFBRpFJuDb4 zf_Q#$K+LUrTuHvOkbixRF?|CS0A~LzKX8rB@BJJa=oBnB{jZmP4nh5t!GcHO4-=~L zCqQd}hom853-5m&j~?JBb_5w4tdLnEMXH{g&PZuOPxtea*kc=quRuH&)Fi3sXn9NM&t ze+7N?V-XE`aY$1+|AVLxDO6m5xxw6f*P$+cL*Z^fCN1lJcGWGlsNK&m##Eigqmwu( zp#f+WA992F^lStxZTw0rK+*j?hVkUDP=?pLu3E7J-(P`#fxmcz_42(sf=ndNIu8>e z^2N5AZYx*!uTUN;SN1*1^$C9&%4<~6HcBqmEpSn>2Fk^{iYOJUk6f&?3N=tJR__02 zvE2XfVx=j?nxqu#ZK+s+?R*6R>Kc7I4mvvu+G|J6d%xp_rC7M2_Kfa{RRF^ z6I&u~d`+-a)m?B!$hQE=DTRa zVEzn}Sw4oFC`S$dVwbch{Oe*Q)v1N^Gq5x-?UKTylJo=!j+9)|0A$hv-%V0;S|D&OT&19w8%G(0-cMI&D1U2d_-9>}Yx zxoy%!p2DZ}3BKqSZkSEvAKpS)7xF#$(vJXRCCa;qUqsYTuM}(j&S=9CXN(0nmOFzE zeRD>PHTYrP?>6h^F-OwTC{+!Wk4~drv1yEQ`m7bqs?gJ}5LFe5?N&Z~O$&l>=hu&_`zVj_i&7Xc0HDo<-%;5kObi-Anj;1ApuI zhj-avkC#VMaY#vxwRs>lfYw^TFW$wSyde*B*2*F*t5G?1xk6#m)mC1DZIJoXg~ESe z(dW>#&A2I&DV3Qlc7$~}iIaMw&KirBquYls2P$yJj5b(Z;lFSz z#9!2(M~6$^2A=dG@Z$^3JN-o=Q+IfccL}yZ1~*vDJI_WCsGl<%j;;l!OLx_TI2_?Z zDY`-O9h4bE8RGgQ6p&7aZHmyghE0()2SvKA=%`2l6nPswLv4y^B8AMe>vO!n$J3{0 zp~yLvc_(f*b@CUTC1rkvGM5!)=0ll_2ycKgeG=ZAryZkGk9_Z2?R3B6G(IcU97J6r zPAu%^f(;Pdbn7~TKO}1V^}eDOi+0Y5?p>_4Y)NB_HeZ9JC&2p`)M=XfFnaz&QXPNF zKNPhObRLTH+9DE#PLa!h6}1n>=qq3QskgcE>N$0%QVVWh<-k_XtDQ^ddYB{epiT3d zkAzH&mdW?`tXZ)IGqN?AVrClR%eo8hbMLlBOyyHl+FtrV6sZwz>ks~>O6#MKJ|`Bv zivy>*e49%9Kuq5caNBW$d^zUUK&iv^7myL=GojaX=tr&E!SV{u7+Qe%4HTnsY@GGF zI~j9~wU+l#Yy0Tu!_-OjNBE;^ZIFH-s*Xmf3cg6K_4N1=f=M8HbN?J@h@Kq!9p9qX z=IYnJ7>!A>d+Eb`h?927xYnaIj};0p;~Sh(){O`YP@@6;pIC25Jnp5J@J*^vzK_QV z#yCNU2%=u9rVgOCHU|mKzwj;^?L<`{6`!rquI}Oo+i?#w@7y<7+Gz^i#=V#}NHtx< zhcj(IaVmd?X(xA^u{zo?ZYZ`v1#xNJdX<&@#@zw@1k?I;{~cY1?mLj#pMu%PfRDo8 zjQcTTI``6QALvqT#}ciq}9Ig=*M>O-sRB-7bWFmqkl}IJ0bO0Hy_oAMP?>M zq;;|_59FM3KZrJD18apH6Cd&Ka7Vx4J$2e3%S?!ahio1kDb+J>l8U9pXIpta3~JBi z*7B{eIT}fhA_;BXw4g1lz*Ksn@DQ*j#K~FigNn3@rPW1U98>_-GDFuJoBcz+>V*ER z_F8%XGJU(?&M*w&2ZYzWBk=EZS}%_oh0%un(2r^(Htt6QT{~ICuj{lAjC3l9Hi*#e z6Yx(v*IFKn%|5zsM7Mm$or9hzQVD1|XSIW~sDDV4XrI!R(2bVzcpG!!v%oojMh}b5 zRaAMGf2h~?(Y%Do=vVvzD6I0;XhYW@_$|FQ(DfZ89*+Xi33};2Jk(j+%aW_I2IOHs z(_Lr|K>akJ-`)?oC*k+@(Bbz3=4S6;xel*GKbo&oak3(R%oqfne+Ms(Lc7iIe-lKj zSri5nT{Y^*4F4Dm$go(+7)Jgmt@J)3EK!KS>>>0T{w%jKjUa|ytqb%s@hMHfcF`#;XyQ<(WQmv-lv1UAh|H-YRemHSuh0q+4P(vC z=_Nw>x%COwIxITQ#+jSx-ZE7(IpebfUtgiaCCn5Lon>hmO6`x|dLu?sWWC;O7-T3~ zK?!K>xiJFA-$>XWe7YItA@o=QymrJ%v`8TEm&mGs=`dTJie(WV{Yn==&kH9i1ansq_jNg&?G=zIWaLEkSp-jYyHeE0qoZ!@obC2RIY+_U0 zda-aVrZ%5FJ*f9)WPVNU6-o-d0cA!i%6xO7HtB6@ayKC&-iDp84Z8>om7bY^e4Er3 zA=E>Yzk)JZaW2|08RIS8v!^jt<&^VNpjdg;rvW=fu=xOZJ?e|Qm~d#f+Pi6Y&=54S zue5xlG$bSFY_wq;`WBf;aZseSSgktrM1R>t0=m%5RS2EPpp-$j%{0s3wRlHs&iHxA zFaY4xr9x&gobuH9VrDU|eAft>C(*3*O=d!-aR$v)kBm>Ly+dgaB<2C^8Br4nzUOBd zgk+iw=pc_wh?I&u6K&`OQ-Y@qt-gy<#7ziai$aj{mU;-KEdgSzIvs8J9*j58QHxQ% zWM{O<+=Mx73xw@M_2TVj*eme@PX1rcVDG7DLzb=7>m#hiXu2Lsshv!tUaG`0k4l~h zrKYM?nY8etn#AFg#rUuSPDiN;bb*mfRR?6&BTj8ZMniF2Jd@&hsMk0(&Wynpv_bhR zd_~`-YoJ(1%wy>aV7jHkIDZczvq2|cMcmZ2CX5RDTSK&TC2{sFOM2U#L?7J3n+D?` z-%Lz1{){$!J;>IUL#$LI-~7|c@$^Ff1~dWQmo_^Gf^qs23jY*ZuYu(&K1b00n6wrO zq6tFx!+y+g4K#B(CL7;8@;7B5IbF7Oh>P&f-CkbUPODJ@5%4Lu>jAJ#+hp>@%Q92b<`g2@PGU;epbCQab| zC2OKtHq_{n5hbM z7?zD)-e0i}0+%TKd-ux%S0YW>aJhS@HMS=E3#!am?6Qy@(?S48x3&82oeD|Wgpi!^ zR=A9~I9%j3!xP`9XziiO8Go#$wZIhjKq1}o`vwg~0x*Lz&aq|qJsJ|3C7_A=q8WuES~fO^{n2TrLmBEsd1Mr}co=}bNbD~JWPXFLNeojFPf{`q z!fO0UWcHG+WIVI0*3*)6;a~>2s+)hy`Iv3&)zOAW!9H7I7v{jgP!WN8xg294+UDd< zU(^~%zo$t*yH1=!`q{;)6@@Qr4SSFuwq`c!qUFe zMm&$oq{NXsYQx4*CJ0}zj5g5sf;}|5HJ08F#ZSR?g+S?Hq;xiXExe61j-wUvGw?+@ zjatcq1Hl`DQE1?BOt*Bk3d)4 znhDK89gu-EL<{&8M|o1T?$*JuQqb#wCBsNiPD|+u00GLZDE8LDq;-g+pwg_UzWJb` z2gSz;5%)tFRZzl@wPCLh%Nc(m8Vf?iLr>I(Ee*;U--2>AVY0MF6ePFe_2rj1_kJV@BSLZ&G9&=O5p z5LBR}(@=SvmapjKU5QT1l1DM|fD$xlr0a!GJ1$Z}VGr38H_OJ$V$87Uw0at&$hLnvbJB8?bTFfHseE5sDr~e3G4# z>(@jZGNEKW=)e4hX(F~>u3?XI5fp70ZEnUj&7}iz(VlFEW4D6}Nuxu+ul_UkPQMm>0F0?`HO z%v~|D1@A!cQVkeCkvr7TO9wMdaF2vNNEn*A-d#u@jb=QhPB50nNG0$t#gCm9<*0?h zm`v`hhV{mQ;kr~!9;M9)2dITX!>R2dX#JLG!$71Z}XTU_-ODHC$el%iG9Qh@iKk4L31&z&a2!C9(7@?Cc!=S};*1`mcKrhD{ap=X0*K zCU=@OE3%OfF=6T0<8mve6Le;#D@{c5;(uDprFHKTW-W$`Cs&_Jr_7hMgVSM>XJJ*C zq!pfx6Poe<9jPG_jt(-^VK@qRTVKzBFBZFqb8F#;^upjw{NR=(WG{$Mfw&M(gDgC)uVmpP>5PKr|8?l& zRI*~~>!|ptn6*(U;hZ9<)Ko(fAEr$-gOk<)pwpC`wvsRNJ>D3v6Q0Kn$ClQz-eR{x ztmsMyiuux!(`xA4fHoQ8CKMS3-%jL$Wc!u3H|nvykwU{!5H1qI)zEC77F)X}4C*bQ z)3#v0XcmUCguR$t_ZBi^F`@iTY5#A&s7*SFI!I%JB?8O4@e!zK6Q*9cXzw`{v(Ra- z*v*3eG=7n*>8?~*?mO5kK%MlWI>C4x0HHM2QT+s9LIedvVQfW}XlaWIO9<4BEW@;v zHcTxlOm=`#&%aIaSWEl3OefpatSZJt^6cC=yGK->WdGF$7=y=aO?)3c9JGdXz3 z=ox&|obp)|V7*d0Re7zU6bjR@bQ^`3kAf}0A6ZEt-s#%_i=iH)!_3%dxiU3!&1xaD zTx&t7m5Fm ztuv30qG%re>?SZ9&Vk$qkb^UbELQ>{flY#Pcn}ap5hVou3G%3m_l3J8Dj;49$SC?e zgrE=EAPNd7_$VRqAe#_H#ar0~d<-B6%8_uG_ggcw8hxKX_OsL7)zwwi)!o(I)!hpw z5P{!VMi;p7h%U#O$b>>i!>o~xXxo%t8T-=wKJrekUo)Wky#zpV>I4;`ZOkNo&|Qx_*@L2a6T7+GO1r0(z-{- zY7AN{zdhKh-)K&X7SK~~a-gh+(yop1Nvj=(i7}1*qBC~OFy@*Jv8*>mTjMeLGx~nYK*F*4#O|}F5 z38&Dj@iR!G^)0`E*99%?&5~=HL&o5B#0kQx=jz=)_FY^Iof0nlcfI>5!l;N5&igk{ zHzH7%6>fZ`UVBW^Et{|Cm3sFg$sbx7d&N^9Z3m3QiN2szL(*BA@OLHNQH4g!Q-*DT|A`T7Tp@u-|e6A>M;tcf+~H~VGzla4Sx z#kyK1v6L8Ob7=zqCnwLqf%ZCov;MWWbE2p4+OgUT@0*A6UGv6jtE}(RIb*dI*0*>5 zSnXNsJ2-Q!_EaeT_OaR%)^{>6i>+_h4P&)OL-9ppwMEu1a8Y9mEt6Hu;JQ42G`PE%?9E<#W59yDjs26vc zb%O0k^r4Z_yY-m9BSr1mKd`V~YsG}Fp|p$n9p*^9)I|Ei`hvrw?Z$WQ!}ab}sT|E3 z_;7u}kzwIR$P77 zb~Y1sNkSL%+ZM-nS1Usj-4rv^W$S94VdHi))yQf809yJ zDT?zQ9~^DO7tF19AJ2?2VeawNJC6^HHGxu-0E#n#?y1*~4~+L8hno}m2}azByXy;1 zj7~JZpT4`^eXL)S@qPQ<_0D4hdl=sr-(9aA8`jhKK6V%J`Mpf|oA?g1wH$-<`rIDA zV;yaVD|{EvU1W$f1NC>-JCDw?wHyU#V{VM^SfUw!@f_mknBN=kq~J93`}PI(1xM$b z--GT1&p@kyl%Bq$ciCD7A(3Olthj~*-_iRl+_MvW$3~m^wA=5fcOQMo3|!%^ck0Q# z)i}?Ux7Qc^Jl)pv^BrU>mT=4M-9lKvv1As;yce;wQK^Ir?vk)g?SHM${qZE*N^s5FYC7GS^Qz-SUcms z%@(ulIelVZHBpZXS37wo(tWy+lT5PGFjUrDsrc6vD-X0iIypeGADM$a&_S^;yDUfi zt#%qG>qPB?Q<)C&Z!Z|5&88oJw%WE*;Hw3mmaRl6c=UC6u|pr5-z3w_yN+G~xa&;B zo$Sj-v_brmnfOPc7q>~1!d#QhLL0}Mh2BM>4)&dG>^IAqm0;M?)p}uHHO?6zXRmS= zk|5{JY~1)hvPCtqaURiInc~n&?;Jau{o6h@2{QPzTiQW?xvx4sc~eA^Yq{gX_!bA# z2}e8IRkAYR2vMd?1xzJJ2;Rvmof_5yDrr9 zqRYGxwqTxJ(L1KAoo%1$@#$)&ZNEMxT^-ymmMq8h$I{h)xFBv!hvc{Qqa;lHFXe4L z_b07M7OgVE2(3#A-}N_I-y}_F{U>RsjQYs)PuF)1RAVox2i~FPJ`&iH zKhOTNuC!Mi7sZ41Cg237@^3If@~&05Uj=-U{x<3O5LFm?!+KUcr8f*#Bkh&L_1g!l z3AROM&^28D*I+fqcCQ%>4l{%E&7ju`7V18y8fCLWS6i5dny{11V4)QpYX%i7I9!5J zwjn0$05j2L1$&yopwmo^G=dJVbBrd#gB~2i0e<%oHM~=*4c%e`b_wTKbng%~xo0~= z4Eg>qGnA-nS=EX}gfbl5bnzH1B}0F2h#J$0%feSSb;VK+BiF$p>P0DsnTed7dA56#-eBS0&B3I3`n+Lm=|4$kAJ7=j=^+O2=<+=crpJu(Os0mw z)HE=z0oG?>zA1T(wuU7D7kmHrZ?#25DBk5=X*Qvu`ySW{r@1V^G++MIVeZz14p34)O zN2p2D5+SUq#N}V2C|hL&Y(O(!8chS_f0Dt;pLY& z%TULse%2=HQpk5uTl28?WOuu?uj9o&BlTM|)pV`04cvHPvmn8dtcPc+ah+dllQdRe zo2icP{6d?k82wZx3#y;d9@+!i=i0m zmlKD_n8*L)aLyMJAw5gjz8o!1toSg~rIEEghB2EjfjC)lILf~)0!*MgSi|=akjfGG ziT38VB!5IhjwViR$)t&w!M!Y_T*=`;_H}7ce!*pdts6>e{YWVn`D$@ zITrOHTTOiFt{}5Pz4W>-WvNM?f3g)CLlL1PXDaCW043Pc>T|S6bo&?>L)b#Hc!@mk zhOjBh*2BPr3!8Y){S#3{SvbP>N(4*l@%dqNvHyHVo5$7^B8`1YX$Q4WawM^C(Q; zV&uv9-vqHg5@#^{QI00<;5EX3XLu&@3oSfS`4zytZ{fHB9G_b_!amH=o|XI-!+{Z8 zha?;?CsHc^Wx9Lu+#-(ML1G)KD=*ys7lu8=VNA>YFAZvKVgITpjKVc14~gW;+i zZ9eItx^GIvRzHXK_S5Tg)b>+8Ak7d0b>EIWYThkrDR*?>J{$ps}~@6F71 z;N2W;Jl`R?ED{({Pb5ynk|a1RvD6b+NVu(74pE;2h@Z@_nY<#Fg~OD7hFm;Eid+(m zf-mt;K`w6-XH@Er94&R2z9UCX==(5nAsPIK?~n{e{2_znp5X`dv%q${%F2IWTaG66 zpquo`WbN|q66hQ+AfNCiSXa~okaY6A=T>Sp9kg*3&&;P zsIqWWug}pQk^Et1AOCiUUoVluujo&R-*$ZK{Qko4n>kt`u#fW_Z1KB=Z{c?v|HAJS zi{B;i`wMYK{l1)|VSq~g3V4X$623ox-)M{0?$Elz%5q?BjwbDGCgH#H+kyCjZTP(w zm^&>T9l=p-;ixXn(Z)!Ai{JZ0{JMw~enpu={C+^(^uP1_YL1pn{H6SUp#V|nyZIJ= z@8w_k?Pl>i0e)X6&fxc-Iou3w@GIaUe)IW$34WzNmqF*#p-R+MR+a;+X|nK@MELLg z1}Nf)g~w>We9J&CIBLO>XW^)Rf%c1MUWnh3!n8k1{C9>Y5kFsO4Ygok-mq|7297EVNA)sVu;dT5;1I)JB8?U-NGyii@vZaw3&T1s z7}&@84YnBG!ne_a`8Qgy#qbgs{)ITBhL_NSErtbN2*p0#TEcf449ldY*dkb_0%HV9?j9RC4ZP*F8OWezgK`5o5m0+ zjCX6pxYWYce`9>YnjN?vKock(Ta-_5@;-qk4h691(TzmYg8xZ0be#qyo} zhk2?W9;0@+RPu&|kjwYE7E=f=i+B;jyWp8@;hFPDj+O(nafBV=xuYgf{^?&G;3b;Ouw3st7`GFeLDhJv~66;6qoCy@o;T)S-)XEk-1L*u=@b#D9IQM)g+4%atb zs`k#dGoT7)mM1c0qBf9eLI)*xRL5}JD6kzlq^?PrXp2Z+O34wdODE%CBu3KvP^oL< z)wpXr1!YT1@%=kp`j%Xye`2baIcAeBD&>6cXZ6SadXe2>WE{pMW;6d6FuJ|a)pnU) z(pz5VbaeK3HEE_$E>eE`lwi;Ew^l2LktUccPsv)7j7km`Y9pAy+kJ9jw$woJWqX`* z$@)6@xh_mu`2tdu?eu6QE$Lg*_Y~+`#;X^3PU76^!a?M2x)oWXOJPb@NL+Gj>y|y( z3bH6Tm-`H4rSfBPe*~tJlP9teGr`{lDYWLxNl2B`f)4*R@N=Qs<~tZ`^W$IfiL716 zog2@wRi)xgzTzuo`$KtttWPdfS<5QfZ((5`rtS8Mx8{uT%fW2`K2aXd@W~FM07(45 zh!>J|luBlC^1Xdgj@Hyw*}ldec_bBw*K{|AfoIAm}vfc!SglTMcFN*6!k zZv^k4Z(4P6{wQ7di2rPu{>=on!+?Io#o^2|E6jGJiRA}};^?p&#gaZ){K}=ww1qj^ zR=x8?wO9Nx9BCAiC*boIWy9Z;qQ2VC7^Pn_QJvNKpNLY-+D=e)NZ&kB?U(rmsd6>8 z*$0LD&kR&rCWr=viL?~|A*9t5#;w#X<6F*=-IA2vb&?wG8E#1+{GJ@`2uxQG;HA0T3{hvS<*ytXM{7wtecdE=#0VW-SYwYlVxO!`Ye7#$^!5ULCvJUUWUmvP zjKC@5ezu+ta`@;G3K>NqrF!Sf)I`rc1cT7F2p8XT)Qr?_PtXO`amKb{arz$Tf1!`2 zfQM;x#cJr3y&6exWbvri`^d9{k`aZw#Yi-uKRm~sQ!}SiobY$kKXSC0lu?EDm2>~Z3`aS}<(kgft>lJgborg!#%BZceR-S{m=4j){ zBZU?9-{Y26wfkh7d@O}sE%{;<-y7r`63RC(N6YBvF*7#y-J@-4Q&=vUCP=14#rF!C zdRv*o$uxk%<{-dix}~0N;?pc5s(SBni?+J)v*EoL!kMdc)<+>pr4lgfDxKJ#IM?g(nX_d7RHuNBOU(PpiZ+rwdA9 zz$ER_hKeE)$3SDsBXN&=h*TcFp5azpW5OQiMa0oBX14M#dGiwXxcl+#BJQjeR~5g< z<4h$`io3=sD>5~HkGluoG_cI$R$N)!9;XZfg~&r%fI&>$9(N?)R9>cNkHi(n?s0bE zJ4l7D?ci!zOXoTqITZpUn$-e<;*KP+DbrYdN%SZ!Eq$RLC#p~eQLj*jqhyebyHKqZ zrKhhL3BAYAEmvr)>8PBr;ec}xC2N}ldMMr5 zRNFo_9``&t7<_}b%P88a-*%0Jq7LH$%SK~g9YGk zA?2a`1YdbcQu9jk$h~h%IPW5`;nQ=pr(j3E^SbYGs<2YZ1t!9R4IL53#r_uFye@k* zksI<$$xO2%%R29I{s$%Ee3Vg`AUfA&k6TZgtR{Qz2C4{^dgrPv&hfm+jc`fL!#I!U zO;j>VF-N=nAYCg5F(@{iqRitta!NeX=t9G$qLy^eh686>JAV*cH_XO>mv% z7K#SCxo@b68SCPFQfnnpBPcq66D0@Gkvbv!g;czSRC^}!KP2rn%mGKY-8@**=51&x}6Z;m1UR6i?qQE1E zCxvU#G0FSw@j8!t8VR?Oa2zs?%-(39n5RO>Eg^)Ju|8Qj{tSdh#^yTbO2RYA*GN8z zD?451?!dS71m9b6si*6l?f5RWDaKPptb#DBz@OA>}_t5*M$L^&;X5WQ_B{RP6}cKW!!d8#^a(31ou z=|^hq9LwuR0of7B1qV|#oENu^=m(z<3gAA(#VSv2SD z9Bsg1J@+a#%Hc{dT<_l2Zy-Z%G-h;ZiEZyT@UH4(2zK~VF7gu3jkvIcj#Ae3E!kQ` zA3QitEd1=cct`E<24(Npn0+Uxz$yCntMJu&LI3e8H7)OcC=`c-4oF2V&-oGKS{4N4 z5SX}%x~+6+@ZWq#S4f&)T=q(oy7y`fxYC^D(;ouf%r zDK;BdHbqywK+pO87p;08J*7xM%-833DP5!!$efNcHIm@d*Iung=7y0s86A=R895ZN zn7kYlPH#sU$utPT|8iQ6mJ+V`1Ww8k-?{gZ$N(feO+R%tGB4CquEC8XP9JrRns)01 zl=@KGIRD`TxuZA|d*leSk4d(U=}*uh9L6YPCbCRMjYBx%jpcM|a`YNb#x7ai7u}RY z;ESjZG3Gq{%MG1pE|YtEwTxIsaFnwZMk1x%z`v{v{5t1Pned8Oibk9<7#;F3}j%V>pv9fO^CRl zW^kO?I1DfSiF(^Tf^jxxt#+x4#pZ~%m3+5U8|-gmSI83oV=;p;ZxvyKHiEuXdg}a z-SygQ)%ecG!`VL0?@nustk|xfAdly(Z@D+BI^Gvx%GCA_rtER&TS$5f@ zIhhhuaS2x0Z6JUpUi`Q@CCiXgQJ?bt8~@Q=p@S1r(u%O7 zr&%~Wh9bM(t8?!XB?DMt8e=pDS1it$-l^XcSBUA*6c+B=DaiQm(gkECCNeL>sVv3H z_|beUc> zLw(S5A85?JG?8n}BGW(OgxSen9OT%~Z6xS!>c8upbKtW-mD7YW(k?R?wl+P)AiRY5 zI8hwZ1lImao%=i$E-(^K++J5u4vee~Nch~kIxPlR>DI2M+$K4BtfbD!B-nwZ8leQ9gC972PS)3cB+z11baB-$c7f;{~a(h5nF z{_ZQ{rSSJ-Ss9`QY)1ovC0&eDDk*Qz%GRDBQt6MeYDMM(coH{TX)Y(}wy=`^NNb2; zU5j{ECnZCfqxk;Gr4~v@WlICT`l51G?A|V=Nj0T)^ht$WrH`7aJ~@D|rr&HW!LY8r zCc5k7#a%X*Q=iGxIoq%ywZgzqfU;^RIl`Wz|=ShhR^u#jOYM}6#s2)xAhtu6*g{;c-I%S#!AYulRMU43o}KTh%SeNtCUVy zq#%+hOBKEE>f1n-{-AfeQH@Pag5^;0w)X6%0&)hA`s9wvaLLl`*S99!s217U?K+#S zJ*RJ;g&#=DOW9h1{^KmQzysoF3u#Y~PT?2)Ew`oNOm0hI@#5^c#g66^jFZ6?;oA$V z=p}(qW@I^kRlZ+Jk?LrGpnVANegt|5gMx=KV?{Tvzq!u+KJlWM3t4CorLFuzZaFcJ zq~HAjifi7|7FBT%0Ae~w#<}8K5DkwCnC|;^b-nCS9r^8bx_}4pPMJ}*3uU>q4C#& zO)s4m^>qEz^ITtN|9Vcq!%=TgzOOa}bt|sJ*m1sqo-1o@YY|L>cz#PZg=dJTIbaO=;+DWMxikTD$G7 zcc8oIudv^A7vCPfF=a)sJ-@f}H!Zfeuxi%w4I-ARn2pZ%M=XtyCA!pvKdN?TW-a^hos>>RbjM61mdeBG))g$N6aZN3!R(;?bZVezA=L%Ze_ zU=Bf7CNPfz6B4oXxrO$}v*+5?=60Xz%jc+ho;>=tAFp?9_k^{Ze;AXg5w`!E@9+(y z>`?#sXnO`>x!5=|7j;s7hx)l%N}xFo{9~-s60XK8-&`uqyRLOhur=N< zX1-K^a87$)b3di!JiW}x)&&3N_muu7aV712uc+u|+6=#p=7+yEt)|3#>YQg0*7<6O z{?JY86wjvj82;_bks?NtAXL6em{HXj{-1(kQHkDPW@}wgvc?1LeLsLy;GP5SF)RJx z7ui~S(u0E)xN#s|Tg492C2#S(I(HgQ$)p*#30z|3DVkTO&9QqkrcTHHsN@ERYyga* zuQbm0U%n4wTQ`Murt@?7-UeAyS{LKHfw(91MK?2iKb%DLGr28uc?`+LGeEh#Qx+`g zOt08+;|j7!BbZ87;LNyq+IYZAIb@NW4J?zFBD8 z9DB=|6%h~INsm~H#UXui_n%rlx?C>OA;i!vby_}*Cc)21y1(FAPFz#qI0_tY!GwRD zt!<^iJczpQ(^kEV|9kkCs{9?#Ixl_KDwcGrrE4x(l)a?H-uwY7E={267F&z=fXyek zCjryc&!dVCXE)uE+u|)*46vhlhcU)GeA6iO3G`HIpdpa0)l;b05poBO6edk@8+Si6 zp$fwBh6HM71zn(?YovcG1VGGP6jaK$j0Yit{*O3A|2@?X-#|DMYb!{toTN~xtt+0OUvM8mn8fXv#QW+>J;d~p!ZUm3?akHf6jbV>QBe>Sd5XhiH zTssM`HyPJozJ+E|8 zB0fZfw_74Cnq22Rgvd+LX6fe|=XmJ&s0SG{q+)47&nn(TUngW48Y_NNAd03doykH9 zF)EKd+d=xYbY{r)X$ra8Dx~c4I`<~NMM4Fkf-kQtsHNcK2#iti1hJ4sa*I4y)VUj> zB$eK&X~nqhyRDHsNC9&G%eH7&cCu>U=tUxK3)%Zqvb7VvXeMZ5A&Y!snaH;wE}}|0 zqnScv2Ge22#}h@&+>|JpEiGDodT*zoZ})f*xOv}hTG?4qtEZP%u3t({dhf-s-Lj~z1$SI_Vtu55V}Li<4A5_{ybNX6S- ztYR5jT1&c*f=G{V0Bxt&lga`)yzS-4K`Ovu7N>dnb=r-5yJQ*fkG2bgr#* zw!_XLU(rg^g1FKOYZGC)0bF9G8*@2^=`oP&C%qZ5NF6m2cYq=DMZ#$Gl{C=EigN&o z9UyAym~75@1~58|J127<)fXm{Tzm8p9Ga&|#pnMsn^SauUMGGIC6LjryFTb2YV6hb zQ2wjvi7XBe28HJu;GYX(yMeQ(P@ere4woW$Clo+JepIDv09C)^6upcn&g$ z7WfDC7yh9>WFLBw9&?-8bHpM~w$`897GxSO{^cj>l8hb5fwC)SUV*&`t}c{tgCeyg zaI5usx2c08K9^{=6N~haKOhnWmA1Q}wlXsF69( zz_y&SqlMFXXCY z@(}r7v?nd=1krBbiaUCzcK?_Sc~*Ez^}P$&gL0j(b;~hf7Rl24AYi89vREt5&*=v_ z8Y=q$#hlX?n!l#*t~YpzsgMOg8528VRUMSY71BRs`1+9bQE3s23C99I+2VaD@Zip9 z#lDtBS%HVwp8MLLu60WW*(e-m0LNAI6XcN`;MN!2iIMPYt@9(m#ZQEpOUpXeY(>n< zH>RqX7l4`D9CNP8)~-TgMM%Kd+K9KdHY}E2wOA^$wl?J64%yi#<0gO!l!2Q?)QE1p zq}@Jx*^ZLynpY;ewkJohaN9(EHS^YkpAoM^u4|No0V;ycURLFY>m%<{lcLKZKJS@b z<+4J31_tED@vrrHcd3afM^CouOTZ+nY0=5bsHkLRfWxWyN=vf*{&l1M59_bor4GAs z7W+A}z{(w0bULzM5tTlJII-y80lQrJC;BmNQnq#()hx3jnSpl3yMVQ$bXioF^uq6J z-LfU&!_{;ZYcR4r+0|6dO?&*qg@9gkE)z# zTH#G-g3}3X9rofV#3D4Lm<%@or$UG{-XhwM4t(iPavxKC#Y>|6v3b018e5Tvg)&0_ z`X05vCnsCe*y9kg8lBR&CRWHkhY`M*X(8i@lXQ8AP~OYP1&iE%E~{7Kt|4&A-_#b& zh85Z!W2vL!bLxTSw#5#`vNrzn9;50{7<=--ZM832&1N%~CHAV&z4xl&J)Q<@6*fg9 zkfEH?f5`n8-|K7dRqsyD5ZYo;wJ=moEnoKSU2)rM zY@1y4YKLTf+5_r{ZqeDAq)v-5|M$h{s~=DkJUg+A57NTFXX+yz@^f#SJ>wZ}xM;N} z9jRgoeVf?MMca+3bj>FN;Db9|GqB?P+>f(^8VF7-i^{_b_-w1yT>uYsf8%!p=?cW@|8) z&=P=)uXqp~>(O%2iYV|4E`hCz6Ikg8qblBJd7>W{{T&afgR~fGrvYMGtZd~(f9ySZ`DPXuX~X!mhHnp!3HHBZusC$|2MvS^LTXSYNp*l_R{f8;USlLFmDSF~UH8qzfUxPh>k zxYPB9h3aJ;PDTpGe1B{oJ>wBI!^4d=E7xM%$RODQ%P$(}V$*rLRS$AMmI$!7DgunJ z7z8z&Ad%&YPi1rw_&jbBJzi_LEE&&KMAfzGxtg*?V9rosl71g?sbMzb-H?)Y%=w7_ zf}Z%g)2%n^aY|grJ1MldbE%aBs@HU`5}t;0n*&M+qtxC3WLYbSuSVDc)S z@2GSuL%TW{9MTqam!Gzfz6f?MJanzUwM6w9yW6C%q6*NqEduj92wwu`K7FrOjqL9F zZM1gNtL7k5%J=M7_0wK;sI68Xu!w!bRm(?fXD~^=C6_H;pRq{2a?I9eMjIW@Y5pZG zj_%KV0wucQzj4O+F+7N^ zF7e#O6oQ{kyzHFX>09Ii<=gabCF(#=3p26Yx6HaJda%}5z5E@q9-w!0;X5Vd=RH`< zhCFUwEGm6Zyq8OlB;F2u81bjT5yV46;;J5~bxPHYrWLuYxZ($D-7Un$0LuVm;(VaC z;AC&*(v?F1RIJF<2Wqun0PF-{A^TfKA-C5$4->bKxJoAU5|?^^t@}sfzLbI)WPG~q zQEc=5Kext~aZO*IE86!B$$R;QvJ~H2>)c0}TT)jh|4usylMW-QLWB3F5~)-(+=$Me zJix%oFG&2?QKYl1@1QEZ#g*BMig$6Xql_St_??a2SWXWjMjBi_5su7Tf02v6{a=JnR8K+N6rfiB7xMcq|C4+@I86?FuS%o<=sdH2mW|^G-{)kN2h7gj zA`bw4V4U3*Dy9HxvzSYVIYm{rTuRYt$fl56xWwoZCdLD|b50Job@gmM#kHSXsH>I5 ziN1K6g$UrD>ua4s;Nro7FW;6h$Q~YO&DXmLA~uV+?O*7xfQZ2$UrU9o=X>IRP4Cjq zNJ@w)o5QG39ky4xLTPVzfTV99z4&oXWZuko5_%M*!h~08$zp{)1A`KlNx()3`-=2v zh@B3+AzJQk>qdAGGby=yb<39v{qcm`3msjREirs23mudz?nDmow60THM7LYhBK-zk z)nR(tVl~?Kygq8NI@vP@IhRn*kC;=N$a8@IK9q+$TSg1xciA&{8CIZoMy-~M06%5| zQw_{28r6xm>DQT-??g*qq) z#=M1zOWxOw@$clp)7AP5OVrGiPhou*C5u=ZL0v*8E8XCeROfd7dF*%zuFDOqgKhpB z*fhe5K42*qHO=j=|KC!zZ}&xSkJd^lFrS`Dw5)xS{;#E+uoS|iP22_1B3NnhGQtfb zEVJ|ACI?-aIrZ*DIQW=AO#$BF_la+^B3zT-c2Q)7AZP*r zBXMOobv>5KjN&J?cca1e1m5O!wTw^IS1rS8lUpbR!58bzfqHtm`G5WP(VEd^@%F$T zw6>UO+wvM<#b6qwOq0uKKO)|o+r(5n5ku|g+kBWA`U^DGe*9iA)RWhRprbuM zjB(58yI6D;D=JdI>`67kGwzkqT5c>Wq`SqH*7OJ$&n_N*sCW!BF(j?Vv|8t83N2+k z5~8miQ0p88!{X>6a{`H5yKc005yZ%N7m8a%TtDJuybHz6CN7mY1~1Pak-0?nATr36 z_<}61Z>{q>3J?Ou^0r|E27~zZe)1;BnC3OG%Q_Z`%_ck{9)LLp+sqKdRKIqB@)sR6 zJ1xmGoB7!Vnyuhn1umhp7&Q>py5*ISrw%*Lgj0*wB;&u_Y zfVk2&nz)v@e-PJ}@I}Pk67Bgb<+F(_0}N-7 zQ;7?4IkHo2fpNtm5*3X}xn#69iS$xS3UQvqTIU0(%%C=0?RuJaDL@UZYFZiB5|>S! z(k5;Zahb#w2LFVcP25o8D);{xmrL9r;*#6Mr4ZMjxB=cD{sgoUnMP#rd*ZCNw~MwU zZSQOS>u1%`?W!VcolEt2AG`Kx-?UB>lRMOEQZEFIvkOZOP$6IciFMZ#qqP=n&C;(- ztRjcTa{=wMWZ027i$xo7zW`VGW$TC*ChTHcea6TmE$#+jW$8`w<}Vwq4NFnx$*j5J zU-VxBFoMO=i)h|EXv%8<76bPX!(JSfRLr`Ubc7ckn#wy-?R_$v7ew~Oqcxep<(8Y1 zEBm9Pwe!@2q!sb)A}xzF{h|RKlRe@Lahml3v6}7xTiG)U{nw%nG??@m{9hf#>W4@1 zUkMe0;j0L947rX{#CmA**o-H#7qMU=R>fK7VKUxGmwcz>p+ifFrbDBg2jE~dWk{J) zUO{i=VvbHN1eoPT5|ZPT3>nE!1F#tO%-L}yCZBQbE^av|maB=LMXdNsS>G6CT|)M_ zim6svsNl5V;!BM2toWyZ^K#drik?>ja z9TGJK&rf_mPk!l`Kj-`8ypm3roLYV?4oNR+vAIa4E9q!39*GH#qizej;o zOh0+{YP^2Wb9gh~udjVh9Txo+B#S7exDP=q_pf}W9|Gb}S8htYAF$K2a{V7t8&~q} z<;;oHRPr|g<1oWS;(|;P47st@ia<J(gVBzq@> zyD+48%P}`_7Ly*+PPxn{tjY}K6jsJ5fMoa*fN=n%C^3eQ?pO-?w@gv1ZN6dR#kY98 zzYB#*YyU-D8;M_Br~j}@O-<C0xPnC9H2+S1Bes!&?^PCye<8i+s1zZZC+ zT+$9*9HupKeX8K`iZ2L$c}@M=` z#Y{KXOJxmKZi|hrnDUBEADr4L+TJT!&wD`~Ghq8wrp5VSb-*xX-?@6Umegm>>Vuqv zlnGHM82bjCXLSDy>gY?ZWa27^W}^9bVb4wCU+65W47l_7rZ>sFg#T!2vkU(io5Kao zQND9QBVPHB=>t})y{=5TaD!az5iLpfH0HoU01M%CTYbsBW*&9q~T`) z8YwQHbQ#E{W=hk>`p45wSF1%4-!LJORnZx`?M0^bjqe8v@{wu;%6kMwEaX_6WY2p) zpdDq2j^d1PRdv99h;X45E~*X`{3z+xs*N4t$b|6#%JMaMlCAivssN|f;M!Wr$*T$! zd`URj414x9?g}_R69B{_1sCrMXde?+SOAf*vN=$2HArQ(emEivz3^?oZVG5Rg^Gp> z%sUk9wd@7g>wyA!BT(QP-wU{(vWR;F7}o{WD68x}PoO{wuMAQ5UO?MK*ky&Q-VL~4 zm2``^qIUxY4}mBJAw#f1U~oe~lgCkHy}%&qKLO`^3#V$E%W-8tm zaE}2djr0-xirR>|a$TTcwMAIb)_}&*x<9JYPTIqSLsjD45pZs^VC`E11@|O!1rcI0 z>*CFT#@WC>fPSp0XucpYG}{&9`_3Y!Y<)npTZI|;)&~mqGiJez*-BOgv=KB>&4SPS zcfff%f%^XgS#pwZq?K5NQvO0_NfTtH%LDFp)}U4Pa=_VSA~rSu<$(L31#3391YhVn z_o8(H=l5-38_NRj9c}Oy`e^@=R-vh7fr3xlB$oOD&Zn(Hi`E9TSKAg6jP;4}Bg7S# z2HZEA`d|=S_#_<`t>g=9DX3~qzG{ChB}#l1>83Y zLQB+Ah>J|~A;!q$O7hJl{rbODMGxN&kQ%KaHO@DUbZOF4feAI|2{`2ouBsOU+U2D0 zpiEC`sL&S!ZgC$IDh)9v$NBbI95k*DIG=?3Pzxwp9dOSh9Lj579VmFTjpUM-1l;dg zdGlTfXbaoGRy`jmnDHm9XJ6^#Gy_Y__U8jm=^Cd%nGhn>CYJ=<8$yIU7|?o>KCw-D zVR69uj+Jg-#bEX)@_m(yuwpFSdCvu$r`l+L@}htit9VRHsCZ?-J%WtFp&{bR1fOnE zZHD7~E6lK2#~?mM9p7jbT(&$=@QtbcX1XWf-b(s4A;OjiwBw|&BTnL@#B34eICgmGQoYbe=0T-u-0=5CnyT>onQwl!YdlJC5ML=WEK!+b{yXGBP(0_Pvd1L<8}y z%~$pYn8HY5!&pW<$nK)>YEBblEAFKsiGQY;Ujs=GhB=J&!HS3^ed^0#7*xZsb!-;V`Hs=r=iQ}X zaV|-$Cb^G%5o@*-s=@L#rA`q`-&RrO5xob znTs{C$@~Q`i6ZrGLd$2=nhfx}U{Flb^LLo9{yyK+6lfuH&k^gG&Og5lPZgQ%1xItv zffI=0_9Ct|S2E2I5vLZ?;^@|8Elj4wLDk{U!pLXlmwx`sF#j2hczG9{uEJ;-Olj{mlSaqRWf z7JM<0m&8LzO5=U$090BXXX6ah$~r?_VU5LQeXaX6QjurEWCg!D&D`WKyC#6oORTX5 zF4K)|R7~9X=T9`we}VK%FM&gVwA4uVHj-poHxfX*^6^CLPm8ME4)yUF`UV z#3gQ6cgn&m(}HID842GmT>{~^!Ba|}!6I_B_yNL_zEjezX+kl$C2k6IdCh+7*}Pza zPvT|*7o^|vpms*a%|sMh9a-7rfKz~^I}eGvYI49WaqEFocA78fCE5}y&Q2fa-wSai zFkyPsL{~fkBPdknA_I3q#cJ|YI+TorWbf3=5ZM12%*0iUj>iV6#L(SkMPvf#vJBs% z2?6b9xLYYw|4q$&7!GF>{*7=lVUc>{1R*r8B3?NB)r>0|WyVDj7d)Y6W*bDrRU`|8 zgpuyEc^FSuf~%M-JyurYz-v_Dc;PUvqP-MA+zp0E<0`t7vGf-;Q|6YEehcAb!m^|< z`Rw@tr%aH70vJ}(OcT?A&qZsU6i&*e-o;0oV3iMoi;iZTNQ(pIGATIf}SA=S$WxSx-sRtkwQ zstQ|3)&@5)_y_@#x$Fw5;;4#N;!Fared82MYLZqpLXCSN+ZaHfme;%i>mmQ zJYMkJgvzb@BmdktWV6N~tS|YefnXDq2LY4>gAk?`g7{V9%|Z*_*lwkZ&Gc_Ge)x>A z32(^Z>(T?fb6nD6Wcjz*{-%7p?+yLJ1{Sr>(k-pQX|C*F%CrDkLN`W*?ak60Y)s0M zL9^ANm#)k93z|10%pu1`o~E%bj>K|QpPDLPpY1=i;Sztv`W(5-cuN_uW#GK45)T*N zBRU7{YNhh6T-uzx)_qp3Ox-4T1_)ANcI|svnx8^$=?YIQZnbt$PAt{;zRHHn1g>aG z$K52|R(j_|p3A5qIRa190k*`U1D4vRjJ<+quNVR)?GRmPL`T=Cu}hT&-Xvw(*ncVu zW+49*HpG%CTZ(%U&28l^K=E3g04|4ktpYEbW{xo2ljD1J@3qvnpd0YHyb>{MquO&6 z4h8!OBaH!cxjdSz3BmeH+=ky@o6#u6lo# zR)%eIkN)FEb_5#U&(fmc*rCLC;7);z8&LzynLDL%3wUE=t+T(TbN(mRn3gB-xYdmB zP(K>ua0_K)L#0axVmc7=yyWl1d!D845cp8B18=OyJ4MP>Gpe)@tp+C%9eT)*2_5Vq|5&d%uzq zL&xwI9mg3S;JSdAfBP-Z|1D~EFzr&--f-}by~J-iMkZFYpMC9T6DULwCvd^9LG0(X znoPitkvEb>VR`lOdt@yte^;Ke_tl-Obc;uvTvcEz{I!YdblBq4%k87Zf7`6jwQpr< z>$~u7DFr8E3kMm}8(2D*T@@)Ljr5l~D=R-i=Yw=I2X)&fj?}y%0-_1LOL<(qBQN{1 zLTC5Y(cg$wz_>ho8)*6d~TQ?{{^J>B(?=9%N`0l=`uiea!OB0Uxa`=b&NQmu z{jD*+44-7h7wt0R1t$Fc+Jc**au7I1A#Ti)43`C$cfQ6g;uV!2ZN*jXtS#8gH#+JW zp23G)ts{sI?8wsEQ-$Z?$4UG_Y~3HRbz?!iMBW5EVD(nWU>?qRy=+N_yT8a|2i?2K zV>Pd4iepeLgoIXQ`)lT4+}K=GkN{p8Zv=NA#lxLG6ejg!C@UGuDMb1x@dmz^`jGJ2 z63*+$T!mL>%Q;F@$$(g1lF(e9Z`+&9_O%eUlvVViu%u;n3J_+hS7m9Rby8NotUu)c zh%(NjExY*_KlxJfEd>6&3z2`rBb^}y(YMvQN1;YZ$2i{Z@lXg>%&?0pB9ejsf-+Y#uoIqRAH@(S0l=d8x6m=IvH;$R6gICc7 z`fWP>R-D2mEuL<$8)>3CeXT-Ep3l+}P?@8sn@n~Jr^@z;lD?hGV)pa$q9bZe!fu{E z7LL#8S(^OcPaatW9uBwr&3prgxH_8|zi*SVoI5Bs1K0_`eO3XkIrVq zv!v4HzXjai8(ZdvTIVSUK8KAW1y>!eDVRf5ig~)2&aMI||cEeW*}9z|aAxM}}0>X?ihEMZAT>6t>A zUqo0>(4mQaeq4GlgtqRqyp8!Lby)-AxX2 zRvw4Hd<@MruHq+HHd|8cG8_(Lr7xLaa~Y-8TY1x#W@$HDrB$u2bw5sgkXsoWN^<=d zk-wB7GmGU4kC3>_%9Ot(OS_g#11)(rhItbt!}0f6+n}o#o6{V3qkS8sU_6!8HS8CVU8oK7q47>8pcBB7g9&E1Iu>zg8iJaceSdSO}E}P zQl9lxuuXbx#j_XZf?f-43ko^t&}+N0U{QuLZ8sg0bVOyO&9%oYb3|an;M^mQJ!uzO zr->WDe(b!W1*G44??|2u9op+T+XC15)@f%c{AEU6A@)#L*Y@=g+{;$L0O%2ojYxOU zn_t6yp{XJ!#B5ZUH%p5~8codbrAb!as7#Z6RBJbpNl)g0t3u`4)Ia;;VBGpDmWsr_I3M%3+j~P|-(h2(cpHrfYpCtjF6oz1ylQ zNJI#D%af(8qbibs`6xt0qS?GZU{hQAdVwo_x29mWs4i8)@>hn@Oo*TJNS5{^-Td2D zUhnw-ZviDrKfVPe45CHCn)Eblt)qNmge|1>ufV!iFwj^OV^i09<(3Cz5mow2P~u{O zyq~vV7qwTW-34nMX&0qTirsvN^Np#Sc^lssz{hBmc`M&SdEQ$!+Q=AXjxp`JnaE=C zWybCwAqY^>LgGfk{t&_#BbhS^3qoQ2Itxkh)*9y}0#6-I5zPg0{)1UsDXiaRv0gaZ z=rQyOJJdK&?|ZVePdYOEt()l2K0od=mF8Wlj`rUl=~(6oi#y`du=>#yY5EUgLX|7t z%(e9T96(&DU_M=srr}zK2^D)}5gMxS#7*lM5uTnffmJnf2k{}_2Oziyb;PvSE1IxpO{&QlhA%)fK!ngsbC%ztNl=k#N?F6ldM zUFIL9|D12@+~F6hij^(jq+IT!<+qG$DE=#*04gp8)9$u(h;ohW0`7s<+T-QmT?5X4 z@V{pWDldV$A!`yUY#q{AOfIL@-a2>W<@4r_>nlX9QZ_wB$SI%13m9SwfO z57qyhYS>d1=UHRzs%)3>p#y@(!C||cs%*k@Xg?=S?H$cl_Y7(MDNZgLzE_H+*6(xI z7JS6*Dx#K~gk+U*i9BIHwNBeFM>xBgrv^uef0;)~UXU7LMof}|sPVa-y>A7tALn0; z&X<5xd=a==YNc3gmFVp&#P8?#0BYtUOj|M3t?1R7f;)*5m3@i0@siH>3w#gfJBYfj z;#<6}ynL^a33p!;DC1etQK(;6n5FG!C`h9V$Wy(amZ_D$j`|N=IJn5dg2@Nw3t$p} z`6umyk$S-r+KfCyyp;a|JVPwAh)fvTznlh1BW;$R*IGKsTVKP{NnFJo5uJ=R^WaXF z%p`nDgxfw&C)m3O!V;1~y!#C-jZc4o9u)&-r=FYWMp;)MRHmum`yaqouK z;a6GoR$s+L5PJU$AMiA_biGNl0~%{UAJc8AVa}Hq*18S)XGi-oIn>9BYpe)ec+t(B>G;WS>$3_1(yWuy76TB22GOTQfSO9 ztks4Knl@)X*E3iTv(Od4SW}Pz4h^L%@)Zy~htg&OAx zNrwnYuR(kE5-ud%N#e2nofa|DbJxT4J-_Q2?{O%-H}SUvkKK@-MKAsZ!uLL?W@1?% z54{U*1E!QAeVaAUz3*x~mT7uYcHy*IcM9>%&?qogB^;M&>2YpD=p=dRDT1@c`Q_lP z*-~OFKA^*G7mG()i1~FSx@y!+c|5qZiD z?fI8Xf4jVAYP5F|crAQN`9f2FTdjK)XE-*uFsq|{S?Ud?&20@7Y~wn$3epaewwts< z%b~*YdqBGeUe=K&cv9EZu#%Nnu>qJS2J&{iFh#s)pZ&=iXDNl=Mxlz8x&Kjo53;>e zS6Z2a!?LtHAkwVuB;RWw3Bm6EF;MVDEO)wDuu4Xjc2kI2It?v9c%xxye>`R&QJkkv$V@8AhZye`gn~tMu;b$ ztk1~Gr&teyrTZsz++v!`i@1$gV9APGmh-&^L`Xi)@ai$YDy$Ont#FRHyOb8mH_nctfcF zgJe^A)vhh-hiaTMW4VuC1Zqm>ZII#c)fiq*_eYvsNdI*NT<^9a-Tq*WcDfCI2mTfJ zf`5pK-+W!#et%7Y=%jQK(t(Jyza}hnKi?JPtU+ffm7hwVI-TDpX+Oy3DDCuScn%S? z+aJ(Yb(2}LDVOEos6^y=fEqZ3Z)skFOK`v45hGC$%9#$%b{a_UAyd%mIBw_LYkl7$ z$gKLFz`pyoWK*r$Q(4l9>}fU|iISS7{rkl~RV!~!jV98Y%TLaRY^O)Bq+vb^;SSGZ z?R{%NEaRT+PYG8>1HpkKjP5&*-$pVdBbocj5CQxV{+|WjK)Al0Kg6wYlzK;v^F{E9 z9vijJCh->#`h~@ufg2N1dYvWwqUD;$=P00*0t&4HKGUD8RtHY1npxxY2-8+|m$UtT zI)Mwr+Tvm;n_BWn92zRXB;eE9iZ*%!3wm~QvcF_~J%3F@VVehhm9$LSn7657ioK zu)7m#-JhaUf~Nt|rW)nqg(L;;X5Igbku=jb1(nULasLED;Xvdrb@3q?Md>C%n6xTs z0kTl_r=BJFz9UbiMMVEtq{*i1u(o)wuW?GF5SR%b3$cL=#KVPSkP1=FKaVn&R+h}J zaf%e4ZG)t0dX2l1g1n)E94OPp?5MPXv0qzLun(9*3#Pn#EeCD??85qYuPyMr3xFV( zI$cGb3MU`Y1ivLdIfVRtO~EQu%iacg-qkhkPusxk?pmvPL^@XgIH4=ZEquaE+0+{C z4d4|E=iFavoZI?yk?PSlKt)q)oPL0Up}OsiAz&da=afRECF+MDu%vC_Kh`*7dU&UP z(*{>nL5+4lFd;oz(&0~B{X5hatby3+zk6C^Ut`}TL{-z*HSTNr@?=9BM8#LsXr)w( zz&t8@G_-NeTxFC3rOxK}Z-ZfS!r@6sCgli;J%$_z%C z9n>dz!FbpvgFfLUE{olZFC)&Yuk_(2XABA z0M!S_FODzLt$$x6JZ47uzEto05r~g03zC1jNqWOPjc9Wf*&9O-~cgSuoa&QHN z91zGR+#nDTLGD020=^)40wNnO0YS`Yv;|QDc(99TR3Lx|2}evoFn}V8N)~&_;JPbSG(X8Xv;L%)AMctrxpUqtNrqZJ=>zJbr}`5tjLy$ zFdZkw;=0+EiL4dV+qFm&_hLoXR*Zq+Ez;~suSoo$MVj*5is`p4*obbC=5&t=V;x%0 zg>>#%Ak=OkH1L!NawXC4p6(UX_hPV;sDfpmGMDCNRZOQvk39jaN;ud~g4uRH1bWph zN3i2SenaDPTKjXXsX{j-A_ZKnQL`w7z^%mh>j3v8a3cembo9}s?Cx*z3(YIrDn z@}SP_zg5@OI1|BkB%_NK$}x}kVoMLgmB>!^*+E^;h!%zv!#>Bk2kl*UGC3jdF#0ggm&{et_1uipFp|NF zJR#eiOU)IyR61AR^!gs<05hRm)#hMUwR_$$oAYQW(ly-1sB#^>bX{3w1Kvxt@VpKR zs{+iYpzeJWdg{jmuxAzbeKRv6fVKof@pj!jgy9W$7<7hHhr5qNJKZ`I4*)3w_J7o2 zIGox|k2)m)#sL5%7?J{`AL`iGzQp&xVFUrj^U%z9RA3C`hJOYi^>{i0$aW_X+%E-0 zR3NVr5Dg*X4n%bJrUksL1CaJW!~!4|=o_O^XcgOk2a{} z@tk)8`pOB2>gQ_U$NvW)0|4a%&}1hF`IQ~89(D7 z{$hZ4o#ibuQoGR&R32!4Puzi}k$*=!g@B-vfo2K7*XHobErK_H2 zYk!7t--}9GjH3rg)|+}N3fpb?PDK;f;lJti$L(4_&h(rzOdp5u42_(&i|F{#Vz(jf zTPPynU&TI$u(B4#4)~qaMj4yvXxnMO6881aI=7^#7Dy=M;zS||NR&H~r~#?(?hXKxfjf&83$dlayPPgxq zo7uNcK-cl!<^(jI0PQ7bh+4ZBg=u(s5df{wp*E=5e)jtjm~eCg9*3RTf5&?{`h#wO zpMr*Qx#;-{dla;9WihT9?Yd>Xc>xE|yq);;P~;nl;X(ZKgg4RU!?*#6!;wWL?#RF5 zdU~PpvYe4%RF7m#L&nFQaeMz2*9CF?oN+t<71thdZy#0*-14uuSj2UA=EK-Wc_SWT zYC&YGGZF(CgIDz=d2@&c%nkQZBYs{d>-Ozv#Cb{9?ZtTK(a|7GJySsHH;1UUj~^f} zOI+k>vVDkVH(PY=6SBEZR!oIMcQNg_V_j#Ik2ryacAbQ^gWBJXFwL@7_tv6^GL)@tkn3M(*d4_%vxux>b` zHb)b3AqQ%~_cZ<+Ym?W6*kB4PzK`8ol09+ma^8G05b$>l+kyo)8t)@7uAz>-{fjOt zwKdx69$cXjRxblra+eeb;bAqBq#fey$^@~6f2}INoT*AN(j4{z`sR-< z&5Yy?>cb`gyg8(={nle7K=igBW%6-dsQ*YLqgt0@#>$Q46c(PsN{{P0wEEOt5wtgN z2Nd%3;1WGPJOEuR^ZZR01zX#Wzrkyag^l`6XU6?1vwqV}@M(qZ@xLL07@?n`lGn33 z#70a3B8p9brIkDnjd6zlId02rka{FmP+eI(4hzQ*M{paI2p(`vz?MTE+jcjZ-gE37igh4qfWQQ02n@CT`TC+)e z`?xRNp~J5Hj*ADylg)4+z#Zv>wjzc%r z4u#lv{tJ!$du;z^{$sQMiS{NpYxgU0p=nP}#+KI+3w+pgLpbHr>{l!MHY(d1m7RhL zed!6V2#kuj6n|Kpb0(Z>EokjTq-0(B^g;NiULpX6%gc@Z@ei8_+*HFmEFi;Z09P*{ z3vrA9zz^{cc=_DSbeW8gm3)QeS>2G2gG+XogCAOI@Q!`a^`6=>-Sf^<_ zT4WFH{Y3F{8_XL$So}#{?9(NHvk1fBCJ3xX6Yd~6_C8Er7Cpn8cu!q?xicTehCdkps^c4Bgz{0Vo~2K}jv zc8mHl#dwNk{0T>cUD=F3;WhUaVAYkq|EI27J1^AbdpP2u##tu>w0RB#i>}w96T&*g zZAKi?b`|U?K4MMCZ4=%uSCpE5N38aGtQx{G@Uoqz$di_rPyZERRoJDXw#-~!o_GcS z?*o+885X&$+;|>g`3Nh--UC*XMVas|J`=1RXsG{OnEW#S3&FhzPUI`)(+{fuNlA1w zDy?~33ya?XOi8q~+(>cjopJSUG*giPqqRTFj{F53*g0^^I5%=KW!-M(J)RHzW_ic} zp+4E>#?3ghZp7?M;Gnfp#8-I5lT=CPdTWd> zp4KJAICN;$9;j3Ogx+AKz3hK_&@B3J5(Uhzj%W-q@IPgwOk7J84!6P1nKd1}*Ckh2 zv{w6}?^LH!Bv))^-~6o$ZPnw)X2wuXHoECevwhG5@7{1vwLw5~&94Z7eo77ew~EUOEM z(ljtqJK@SL;WqJHUiT%PtPPEjFvQQS_77@wDMEcC>sq60?Nhl8?K_^cfApe?(PcKH zMwgv2QFZW>F+;WX&Y5u_%O0f~t35k7)#z{{_v3W3EEgT?(4wCrtnO>hjve`i=TNFgc;nMg`Cxe$1ev+*LmO7 z0PAukbp~+m%6x$;>+$x-*u=Bw)#>bwv#_nbA`!%gN&r7X4B~UD6ESjX(K6nE_?d|B z_@1SZh$`0Ip$%;TB} zHgDj8EUMYl@u|id;7s&(8aQoOPJVkc>>HIr)ttM2MuP>B09Crd!kLfLW!8}%SOFqW zHH;f#V2A2rIRB0dr$rU?K`u(Q$|b*OYwdy(rUNEwe^-9?#LDNF_{6Ady|Sc zGwLeZvZwpQ7=wfS3EAlzy0m<5|W9T^s*rQ3DQh zS!dLMo3|&eaQ^(XV{G09onNQjfSjO=qCWQ1XssPIsubka1@T;}n1+{Q0I?hpMUj2*`yKVZGe%71DGN>5oTsPSxu)f&&#^d;4QdJ z<4z8SEarc(nTA&&V4gs>PAuax*jO5S<+83ryYZ+JNnZqj+89?}+1qVxW;3V&+}Vf% z6-vm)1oIO+g46eWG1Fhswdv@ojDfvMUDb^`_+>_2iIdNHM|JB>^#^WB6B*bu$Oej( zuYj?yV4JS!%pKdJ%W*#v_yb)h$zQmLxvC137xT#WNYHV2w#`tl_H8{G@_<6`bY8qr!e(5@tJJKbzK{eAr(Xs znf5dc1ZF*@GqIi5b^6EWW*=*|4Zk45#wiY+c@Px9H)S6qpG-^9c)9LykB#Q8#T||p z#_|fMt%fBW#nYt6AAr2)?3U-4{|%ja5*E0D{m_0gveDeLz&l0RuH5%q3T}3A4tEWg zx)4OBDy-e@cW{_BnFbn|6Ol9|`z`RlUSM^vPiCX7c8HP*q>m6ljMj4Y!3|w_`#TU* zP6_@O_QKK%>;tV82s-q4+`;#hy;xkG%(QPHWo$G!+xgQqatSK->T zDqS9)lI}1+LlyG6lV-D3eKUI%+RYP4Lqmqr)6rb2M^q&V;AW)^vUWcCE)Q0%&W|%u zXJ%=uGiAne+`-3zxuvPsF)z^aEs%H^9BWp?gVq+5)EBi3_y+z-@lC29>XJf~yaA=& z!d6ai9UFWbwS%q-&X>dsRgW!M<&OLo{EtDtw~>$XKy}a^#~>PrbOLnj6XZ!ahcq0o zKX?;P8@=#766tYS$$zES>R5PNU4YesILHqNW1V=OMn1xm@^~Wu;BTARbNK#WcpA|9 z>_0bkVUcN@pj-NvoVp1EqUrS=_Pbyp6>iLNQ#T-a2>geWHioBFm4;sy413FTTo1Dn zwXnSRWa9!@sdm%&A9U%SIOrlwn|RjdmadiXoCw=ksEX2hdz#vn{)vrmP|_p_6ERCL zHRAibH&uzI6Uf`OjJwkYJ;@qwgXulZI^NNR4t`-FU$Aw&Exr&_6O9wUdpMjlK^NZ- zwWZZjg;LwT_B(WzZe$;2W%e-+W4grJBFJ|n1L~pBa@Vm{cXT1$A8UY?AN0V%>bB%G zca4M(QYl@ew2-loSN;S$hQy+8kIb}#&nvw~aXIYT9b7*%`!tYf{-!p<5E z-nJo_LBVzkHenRf1a?_jpFYZCmLiQybNUsk;^HGI3d%I+Kiq8GinL7_8i7Aq?Ks;2 z>q8NS{C*C9bb7A4-?EV)+6GKrq(%z5_6B>eUKcEA7qYMFb)DkHW*+&!;nfx7G`k=5Bjq~=i!f$TyPG{ zR}#;3;P=!rHLR&o7swJDb)tR&vTX(je}QE*!lQcAEvl)ifgHsqHtNiSW+25c5CX@6 zKM?Pm0BCe;W^cSlP*@}{51=qytC)iKOOpf{KGBX8I0dM>79e}DLyh3HyMWXVBqgM3 z5KTxq2&p#Ry8z-#^Em~=W~W$rq6Gvq#zxDv{1*UK=>(Cl)+_ri>XqoMR~VadSLbIe zYXQQYhF4{S&OZpk(VNJ>!imbqPB<^J(!09Y{%<>jKXnE_O;obrp&H?3WZmeD8~3lc zsV(A0u#We1kA=GAdx-0)Kb}&@Yn8hGq(zj!?M+Z)9(-*t32;rvevVQKLt~S$^Z8wv zal(x{ee;cQ3;(Wldw=wH9y)-+{-Jnu0)2PGw~Jz9fSXI=8bOq*;p9ztv(Xv$yi87> z1z71?PU@Hk>4FpsjQKa|I%IZGMu#e~i3?R`tp5&!&H3#|-=A9HJdlp9s0*2uEBOaA z_c=EVAAB-XICclMgV=dA?j1P1~Y-H{4>t=gFfmDE-+KzO_DT`(Y+j?IYp)0D}$l0}Z z?C<-!Nq+vIFfJ^5Ft^jghT;-Cp)B4?pdBsJ}}zkAtdBgghB*3_+#L%yG6 zO`Qc9C0D}@BZ;*bt-WCqmzoJWDuG{e)#Dw91uG|S#MG4yW1H96HC~L(=$E`|RRABb zjMPx@#NE(v4|gnLE+_fVRV-b(b8|=^`(13a+$9nu-i+Dbh{#zbg2axB`HUAc76OqS7W9yV9wtAh;G29L! zKE@lFzeenv7y(hB4yqp9-=1KHusRvyumyJjRo`viOcNIgCU*d-9^MdX1;eV02GhjX zpf~s!(-ZBT(i{>1_{|H{mD>$Wj2=MCF9=-{>YHO%2klaHjd0>D|Oo{lfif zhqYqu+(ff&w)58y*nETMIt=`XhWkT%9G{UO??ssMU7CR9(L=q)w~(2+7J3YtylxJ` z=B!3lFp8gcGm>J))O4zxlnx-*gbcePtt-Aq`(waEsm$JS6MZIH*L5?FmUwcX;J86T zClZr+4QZW8({X%bbKqhWei+X~Mzu3}L*x*n!{0 zI=YLGMNC5&&7NqtW^sm(IScAJ56`XyenYBlhOA&*f!%d!T zjJd5zm!Sg*H2c9E%KE$&d&*g8Ly9J)bhX*p>#XA!3-|?=*C?-UG=1%zk!B~-aM+cyk4(4Qdtynk01kR%1$#3Lxjz0;T^>9=J=wSpaiq=I zpfT6m7noDr-{O@`qJ7i;JFa;tHe<5&Vo*Ru1zeB)E9Z@+Y=vG7;AgPU^kP)p9h?9d zaBTbaft@#9AK+Pfy^Y7g>jQ3`xqiWV<$63X9T0f~9zE{NGKdhW^r8nV35Wi#WgL+h z34FAI?um{&0hqP0xT?#6&jqY+3gdn>QX0iQrPIg6AJ-JJMILg`YfkulFR>bOvt+`* zpJN|Tr?sAvzx9vGIxlK?vhw~kG{si|NLwUap6YY9kWH4vHo}!J*awo>UTC+Ml}X}& z6kKEk`Iv`W%v5_JR6rl1T-pq-+pFScHLJL}!su@Bx*RT*^)rYTw$LEP`B&_w3sh($ zhI6PZSU-oAl^VppaiakpHBY#K14h`SR}>=tBDSrquz6D!z)va*V3zA4hK0nVjxIcg z0uNeKE!;VJ*&?Q}|B&tBYqc z)rQk#oJm@}A7L7fJ?ABQvV1Qw%+dt*X@W{Oz#{_}d2;WRbzhfJ&$7KmQ$qa&4K$cqFkCk~K_#N9x>shb zjZ!_+lfL05zVE%uGQGuUEZ>O?_OLg+#U(aNxJ;ci5%rm*7~Hl}{>R~;;GDi)mY9S1 zQTSJ|z<7)QO6^?gxF-;PbI21Ye;6z$hhlcmf=$Jc2Y3j#%BE9$_eVOaIc=OTLVaDG zZ=uLcM7jd@0%#BXQ{mYs%ZzNbQS2<8ErFK~G{mk=><^b?*l_CQiY`L%aIqTym#Ge*X)_N|r-(ucm;Y+%6d|r0~3{rC}!vs;)lapi7D5{7G+Y0=Tx#Id) zMb?q{SAAuLDV3@+G#^BtDk5{0HbILGEH{sO;h-I2WRD z5*`DV?g!SO(J3E^i0a$V!S}P*5kWKOzhs8GDX$~aDarASQmJgu0 zs?a3V6>4sUnXr@oqPYVp+;9dFz=_PYQnEsZsw)7sCtT!1Nc_bz0>s$1w?}F)#I?%M zB1$FwIDv3Vgvz(t4xtXVFhGpVQA?&G#M-h%S8+@JDVt8;IRIS@tOZy^-29ZNnCcXw z%uf%l8htz2IOHbPDAdWg$d#XZg#PKZ$;N(+4-`Xr8*3FPrt@#GariUkVz4&v1*50N z>&eUBU!u|S1I4zYN^5}*wk=Sc$YO%TK#{C@Z;$M2XZ?c2w$T=BaYtf{I}Y2(wCGVl zVE&}{?UAa%G20L%KE?mT_+T+`+yW$eA5RP_QW8LvPx@8WW6S!1$;Mf?Fl?d7He<(- zfQUiX?L=I3(}gOzhzaLGjMd9}+iB>2q!1mhtH@w0D=5JQYn-w3e8-1TV-ga{gaJ9+-MH8%$t`MH&3Wf zLRwDG*`*j>=~h}csa5R_ZmO=8=${UI{`{H3fgPL$ZXh86lLz{7cw@*6<8^t1hTXut zN$0oKehhs;Ubl9aDSMeoX{vm|c9$g*P0Yr>QVpfw)%0`*k-V6&IU!<(4YeSZ0VSdg z3%*IhA~p!^8{}vQDeM)r`=_Yc9&n2}z^=ujd$5oucy-!A<~Tu)TOgnuq@9d3dx0%O z9zD+~1e<~OqgJE&b1c43Y==yjqe_Fe4GY5S?HY5+%tfj=xaaF6BPAxOssLe7*eK1F z?X<1EIb@JxBTNX>a`HD6oDHSQ9)~(6HE1rXNa5C-?UB77%xEXpmnE_jp<*JanZ_}gf;LQyW}U-Ci@Rkbc+V5; z*)Xw_%_V*iHu^Uvl*8IbmfkRw)4v7HLGROO-WoJBeV+rJ7gU3b+<4=dYhx*QJ+Ol# zN^($Cy&hBeb+rb<%CfBWfH43=t$IBcm$)fF;9>2C^q9trGONODO4CH3y+1gnW?J`l znIij~`LsEZ%x4;(>-C^kdp;~ni~>N(j?=u0lk9~#!jZKa;Ybw~?JfgAIVQUV(4CXbAIl3>3AUI{%uJi6@QCrVs^r?sKK6Yt%7&D;Js z4!mnHgi(k(tKp8-y`20BkYQu*OF=d&x@uOLk-BcyHtH%p2v7p=Hz?KMmPbyDSROTv z3yT;`rBizDgR(?wA^KNohi(B-rx+Fea11lGb(AXiD^DzSr5`9XRDs=94-sfcvY*Xj zw6=(|1h6}1@fG8fHg&q7Y%^aUH{v{j1yBI0`4-r!db@T!oCGS#nx8^dmDNO`k7{he!dw0a`#lM9Asv38Nncu<8 zO?H6ED>z6YbYTiSgMkr&@z@d$uZOG?Zrx5zv;ndaskZhMKNR8$H7nD|WmN-!5CO-a z0J=w_TBCIwU%;D)h_h(q;EgFX;XfrL?F#4ujUyL?Oha`A%Ai8Y(a%jt(CW9Jqutjw z!h&vi4|E*1!D12AO>FmLj0*auh1n{66LP+gNa>xA!sdhR76Rz*=Q`pFLf`smw4%Dt zW_El7q#_{4aPVYOvz9mIVO>R)N=LykYEVhp`95ty#0e6v8ZU#N2Ke0NZ?_K(PCR0*{0X6 zx27s&ud{9{#6jkjpVK3qg!BdsM-NsUEw+k|9Ivh-ZVpi|!P}dYWTe%Bf(Xp`Xzk6yV#K(x z!a-_ELMg~soqg#$YA%LVb_`?_`kVN)TG*>l{Jsj~a0QOCPzYAqhtiay3<|GBa}Yg< z;GBHNHGi=37%{A^I^shV3@G*dQ4giR4=<@Xm0qiX@RHaX%W6w&(UeX^N4L5VV3Lnq z5)ADD@L2YXASe-&%mfab`AD;#|Nc7+>4TD4Tf^uy-19xQ^c zy(yS7xqdk77%N8ET$~uDi1z;F7&i+mm@??*`*)^MB z_2<}&Fyg7FrgTop7SfNC(wX)n6+O3xXFtb^qPCJ-8_rI~inFx+pC~u7r{ctZ!sPC3 za2qjYMRwxYMNG?9&Oi=y-$ zoJEa$q$qnkXHmt^wiAyELVSpt_sK+*RS~3?Rs2?>cwVFFf47DW0U|z+f~ae#VL!)- zQN?AQ#dmp4#U&+G2zw<7ASPd|VH4T{T}P5QTCmr#_C3Wg-?5M2(7(1ut#0v%WN{C# ziT}e1p-U<%P;r9I?IlJRPe~OgX@!cd>~ME6K{I)aS||(eA&%D6ee2Beeh-wn^c%Iz z;sZS>OWg`8G(o%cZD%oTZcp*3M!(pZXKF8$VqZk1%oX$t*!^CBZ=dI^)ZpIYY)Mm* z>`Z)i2vsV{SwKjZ_=TVe?uLQWQw(8$4+Es&ZpBxIiISTp7~?6cjTp@QpAwDAUtTXU zvUtE#;+wo?>4nouTe8ceL@#ap`O{fjM~lfF-74c(0vcuW+dJw(E9vhys}b34K@@K- zLzVSot5wo@n2JcpKDydTI!Viss{g2JDkg+&S}uBP>qa87WV!gIcIgxNn*O#Jr->g{ zt@Iw-^)}K^9^y>jWd-V~A6%_;Uh$L_VhFFP>+LM^sRxKrkxP5Iiah=f^+l!=#Grf> zxikZj+40+`6&b${ zk@g+VG8a5rX8-N3WNz!lG1|#noiN^54;cC_z&U@txLnZM*CL(eb1_caztEYZc=Y{>@nW8GW$nr=sqrAu8oez1!l3EKXzICH%6B~|PtXDqXSDIU|t zzd*m6kgZ~>E8@^5LhX5HuHIh}YExV=XMH6O!0id z^2DP+>bkgq?B60T0FX#0kY~0LkO)`o%5CB?jkZFA|LHqX{mFt0{0}>+`kXUcmmjG5 z&8M8CGWiE_q*&JkuUjiqmAd)$d|19KT%oFB|6SrqUc21_WlP^zxel@rMG#Qj7qPNouwDwIw%g{30_yV562w>d!pas z7Y>OZ^IH8Zq%{7FrnA51$}{d~u~?_=-_;2lNB>&(Z(WKj-7~+5H$1iWioeySdF3p1 zP5Ix>HnyJyg7z{s(l78)ApQ5dl0A7&JnxntADr&Q?;qetzr`>7BYK8u?kp<);`D?J1B`_AO9t*I6Fzo)BFAy!WWQqM9X-Hro(;=DZZXANrHBA+AmpKGo_bx zT0w)%2o*RCXpV)$ZWuY`;h0N!Alo`vdRCbE8w(#I1qebQOBy0|4N3j= zn9*FLxmF0@ZgJ0V+g8|tI})K0mt6{AZw--d3*QB>`B~CzVMzeHpCv^K%L0lchf2e= z!Z3gK>XXv90fYQ`#ghKD`J8Ijob)X&OWYKH_Q?q8UBTebI*pV%g;=^M@QN~j>PqnP3Esqr zykYlD#dAkW=fFMYAImDv9VPw3cl-nU&TC=W4`o6Et%KX*-=+N^36>NoTldhGzA9Pm zXH&;W>HI~uX^dnJ7||B?7xy!c`D5?6+CT@Bgk{Bk?DQC^wSJQywu-T>u#5SOm6H2g zUPTLnz$1^u7|GFD=UXI@wlp*59OcLY5#1pJ2_9-0*S4)oa3!nzS|*Q`I(Vd%Vf%i8 z)xLp)wMgYy$s{~`o}C*jwT|fpWxC7shV0jAuiFn9Z(l$h~>@km%I&`$YNc%Pc z4j%Z7MABDKjUEW&@K0)@WPBG=)qoS>zK+S`q`| z94}>ho`>Da6=h>O(|(zij+gwp@W^4nL!Cl)ba0T4_EYVhvAk1k5 z{wLEI%=VBxlxlMf)tH9&_KAa)Vov|3q$cBJdV$u{>08DQYh)Axv@YDa$` zWBeN5EsMdW00rIQ2A+yJi}WqyzD3+NC-z%meeg)+Mns-=Mt+HmuD*Q^R~Nuk!Pj-< z;#1rd(-NpfYu$u?Ap7x6ds+1a>5B0b?wOqpaJ8yM^cGf?Em^$CyBT3^`6|ZfeUo`j zl)_`iIm7Zc#Td0W$*3jV@hWoCX+&@ArxRg)$c9doqHS>RNx7>BspMKS=vbmX%6Zyp zV~jBYWI^$Z5l>3zNc=yIXFQ-$TXaB4aYpO*T&_Ghm#a*U!b0r(CeHK+o_~|O0qw8q zP0fyds7p3XsuNs;dJIex39XrEhxRzKPb)juu6m(nP1?~LEPRp_5?9#gSRx@*vo=Oq zdP23JoHeF)qCC{5Xh)e1pCmn%b#^+G1yQg?yU3YH0YKYc>LTBS!67=*VAny5y zgwgpU;`%f7AZHhEQ2s5$)&r2T& zjo$3^^HT4=Me%ZsoSO%y#&CWdhm>MRZ^a8L)o}?%e-7q_c$5=AG@)g>e9`VL3muoP z`ZF4Cq`Zr#{YUzn7oPKGgQrRDHJkrA%52l55&UR&d>ST%t6s&8)1(&!zC-bp8PX3r zk9?Y>_#beLu$7fPHcR@+Ctr-PHY|!z%w*|YV?Hy^mil_+Q$JB+BrMLEEv4~de)inV zhJp}l@fWX4op>R{%1+LaZbbz&V!UeMs}qx1b|xnm;;xiY7I;IQ6<1Fr@>_K*yYYrJ zRA^{medbDQvnn%JhssfRn=5n1hRJK;n<&h{7hof!0JG7|ZSXZDLenh2-aNx4JdDwgB6C*ZV4!Z|qbX%yM@#nKUN(M#WkvLBa7kpTfvqTzZ3jECg% z(bidyp`%|KuV>9mBnwgr0*hKIwUZQj(sqywMK*e=lwel=Q^Mu@JeBaQqb8kz}Xp6*Fe#Jvp zNIn{2ggcugOHT+ZJF#L}N)l3i*cDk?f`Y9Yd89j=`>ynnFvy36z9)^)pNNsqK=m2q z&St+SH5n>1wMM0VkGmDmFOZ(#g)QCKZ|_Ub>P>M74@`K*t+>wzQXgLU?i03xNmF%2 znVo&)+gc`kC@psTNtBOi*@_RPp8Bg)YwU_@i~smg;x*cQaZ3z4^^ugNEs9T#VUMkr z; z$;b^%r;_`RPF{;Vl#j%d`5GQuQK|tPD~g2)xb+jsWV>|@XHCF2av@*3I6Co@XchJR z%phmAFu3?WxUCGuwQ3Qkq2}<2l`|06332L7=sdsLlWdRF4Rmn%289i zKlTaQdb8g?k)pNv+^3?nVLz=_@RhTRa@ zCu^iskl%1Gc_9L;5m+s-b8Do&BXVOEZcUOBRb2$v_UGSQMHEmkKt%|Axe|+9iYAp* z90&!u6zSdZe;cYqKaALygg`kEz7WFDl&eR!VhakTq@={w5QhU2b^wk>QJnfaz&i3H zK2^gvBV5EX346r~YeQK@T(-!r6iS`KJswl?(%4@^=^zS#b#7MD?DJ;nYo(6D3W2@2 zR_Y*JjA!fDN-=&jkq~D>-LcBkDfTzi)vanFLH{SrC|?D9G(L5LPu$a*Oz>}tet9U|1b^#V~8M5QF9 zea3cXW7bRIzHt#sUs>V!48<35OT5`KgbO#@v5o7ciGo33UY|l$8*Ce=PWlBTwFvWH7s6VElhJfK@-{F_A264G_ zZO3Sjfo=Fq@(?n3w)HdVkFLHb1?Ssn?Ku>_mA1DW07`qOq|?NW>%dHebx}^GR<4G( z-8h~d_*|0tA~yC5DNHc&Z1xw@S&t$YvERW&_xuKFmrq-xTH7<-WSq(!wwz;K?NWcS zs00EOC0b^eKK4D`2+_bJNxlN-HxuDd_;I9iNR!m?Af0>^;nfK5z_G6!QjTXoUfznJ z((dpl$FcB@(okb%=FLR;69h)2=MB3HRl04XG|g+dhis#~R@`aR%oX3=D7Dhse!0&T zAYXw}>QC|V41DvkdBYAPoZNUGpd5*L!w%rT1O8F(N~O0t<_+7AumpsaA`Ra*Z&)e* z)A5fsD#}btlxt9voDcQ#+Gu!DMVuLNqbPlR-mtCsj}NjIvm&V%pIDjM&tGMGGu`FV z;d#R*Ao?#jH36uHaUWvq4RKH1=h&JpQZS4DR@#&>wufAXggm5xC}dje%7k#Gj|x5I zQj!pm>F&4E3c(-E{GBw3gpVuVNmk?4%yKt*8gf++&Kq{Hi7QUuEP3%>iJ}}!V6*au zEo|c0m@QJ0moZ)rK-fHly@W8}@S(9NGf^vFgfAX`D6G;D+IOoo-YYvsK0;x@wmrhW z+bZRE#`M}r?SAl=c_UyB)&wt^NTo%Sti?Z%e{!M6Q4`_6=^n>kE0%V&FYuszl{q)N z${BFcdf*<28|joTL!8pgDWURO#O+0%3EQN^#G=fracTox>?E&+boniEB_fy7Ici*G z4hXTDBv0hpy-hmZZmhdnQY|>(Q+dOtA%m=zE`|zhxDGt^Qi#&3eM(Nj{$w(WnFhky}{ELhRJ^* zaN=03VDGXOyQFTO;OONDDh2P4xyz340-MjzyqPL*MPLTJ%KF`9;k%`t@gExHb(9W4 z&5$PFz_&s=2v?de#UfWD4jv=k+AYO+RBoBpRt`X$9`BmQzTXYe()%v+Dv?GD1&wS< ziPTA01}oDNX@~$J_ezQ6FHCJ@?xj)}zsk%?XB$23E6>xz2X$Wdy;VGba#yu8>Z9cd#6>q2|FK8P5r`Vo_e&oK0NMP^oo;HPuugJBD=f_# z*!BI=^OR;nnG_>6FV z86ytD^{*5UCYaBC8OvTeD!nLt*^T{k6llfXW?sjnHohl(m4ZTUE3-pBO-=)kN&U1} zgD$u;c1)To)ZJq4zhJp#-BFzR3)8u9QrUyhippx@c zM|mPp>3Nfl|3lh@p3jYt=^S_|CV10L7IG5f__rHu?nx=!>&F{lPMGF!1Uf;y$s8x8 z*5RnnRSryB87X=31T;T~Ya?fbU z!a)^fXrBp_OA+=L!uqOV(2QXQSpwT}gk>UZFLBy^$@0X7dBct&th*~+E=Xi4xcOCf zd#Pt+@$g#7tnsKkKCFk_0K8J4*D%`!DMN6-%Jy71d&%E2!Hu8$gY~3|! zjL(hGP{ADk$0hdS zO?CcM!gnj-{*E6mv2!=2l^D@hcsNr617J;IN_h7Yd-s;KMUBXL5aFMYeTj{}Eo~G& zO<=k^Qd@0hCccu@uYPTcpSvSPxP{hU;7sK6wWWC_En@p!jAHBWNs+c-+VzJ1fHxl} zvBmWr0qIo0FnIhtj#S{AR%V9*i?kZ#xvSzYSR@iX!v(q0VqtEIjzFQWn^7u_ul8|; z6>(XZ7?GdMoU>ocY#4L0nbklXV!fIqi_Qs&J>MiP)K=!yV{zLg<%i{SfJE|s4MtHk zu%K_Hg!=Am&3!2w8`G zBNAiX#yzu;awn8v;62_lrdj!KL-4tp#;i;5}Zc}DRgk8;nm_tA|K(U)Unwv%6 zr3jC(3i54q@A1Q3*+&|~UHh($K60FEfSF|&ipfsYE<4~08O9|?_JEZoC$!wD_2!qq}7?fM~S zO6P0e$A@)0g$v_bL(&_wdi>-Q*>8= zCOu0Yo$N#LU!eGxVd$|&;I7f~t_o+>)DynTYd!OA6^JH)Pb!#^K@qPgh%**ji~}mD zRp?L}kF$5=8Chdb2Y70o-gv`eO3^Uxh&!Bk*Q{z2e@DJ!xc?^=@v9fpatZKowj9el zI~2de%iXQ-F6B7mfah~l;`NeknT0nWFVD25LPcmAq2V^;a;^&+oH;Cjb3`)FyM)gA z?SA&LD{$N)V#@$FkqI21ew>3Qg%&a8_a_~z_$2$7xgB>cuH#3_1*7G2aX95|Tvmz} zIjiRM-4nmkC77LsV;|FVWL0x=hnDn~of z4Rj!{#imEXwt8TE7=d&OZz+4Y+mTP2^98CCwR~% zo(D)Tk7Xf7I`Nz@0FE3F>=6Q&k-LWf0IUX2T@+~0kGqzPla5;j@gN)?45vX*8+dp+ zb+0seCUvjUyNi(?PSiHfIuA2atR~4NiE6%OW~X!py)S%_6g-YZWTHD4-%&hk)EUBU zFCr{WQZx3z_k17(-&DftfGY+~mJ&pf4qM9!e-ejd-XclrK!jZN7!x9fALyxB!a~jpX5cXc7|lMPO$xZ&)B&$Fa6^}gL)5s6dQ49{*)399KUYDpM7m3g z2<(Au5CRV7;#f{LJZc<7JRLhlVWxlU^0%*6E7;TuN}+YDX};kd>73@mJd7sQ<`Ax` z{$ab^4FTG+pRrN2PiwGrhzHlghQ)9R8)w+L0oaEoTAWU0jhT}yMiSp{R&eZ27hS8Q zS*xf{R{Ii;+d_K|)lkTvLS}kz*hM%#s-^uIcz^W)+ZAO9@%Zu|u3!lc+d>R2U`DOZ zq@~>;CeG1?2_JE+^&H(>LuucGfE6r@)e zSvBU`i0m+#hS&vcBh^H4%6Bcaa((x*!5)TgL%|10LqbK8vaSkA=-cHTZzqlswLEG- zYYfttsOgDDO^(|WdMd>cO78H=>|scfJb)6Vy?md!dt%gY>uE7k9tEXB)yB}Zk4xeI zb{aroDqDm@Jd6)L7Cn@HgAOONy5nF5`lm2;Q&u(bt^IybS39C4U~8 zP9@}Yvl!Q*#Cf2&kaD)e%MdA4cV&Nj8G7*DSSxQsx0HFm9hyELa(*z{mk5!vWt=!P zeG;;m5JnSCw{t*5qs|Gg6FO|Cx52{yhyCSkc#JnNAEUv{|H?WV4N-kcj~>d(ebcJ! ziPR)QK0zHxd_0X}nqeD%o9c`)kE7G@cf&d8XgIZXBmPylW&V!F0=CL%2;no?7Na4V zrOBRRA3~2N--HE@-xZtQrgPodCX6rGe!srmc98%wz#F6ZlE?{Jb<=-w7Ra zZ=920gK<$A%knW8!*W5yG*{7j%r=*^+a$`6XDspy&-~*IKYDO1b2Jq4h~YatY40e}@8Ghj`vS%f|bnr)KVC^L-7; zBlRFxlBnC7<+WfB8*s>v_81lKusw0In3@gmC@cX;?XQP3j>+(9jh#w`%v-XQSYYOg zXF-03e!**Z9#RCvs8CgqoCMh(vV7{_Y?hy4h;QDGL&{XVE(lB#YJ`bDvOoL`u^D0U zay6CRMioQ*pYb!=}rI#DaG#OqU z9kE$0y~al^eGf`ML|qFJo#aKLMx;@Qd4V&{e5BcjG-l3p0nMn)@D^v9NTgXSLf`Tu z^Yu4$P2e`G=w}*L^tm5ZmTLJ{Eq9NX#aNB>jb-dre?uSXEsQWC*OynaGJiv-{-s~5 z>DC(_;@6DUAr6Gwgc!LQBN8S&=Y6f>sd1(yJSiQe?~Jo-hQ9F|zUNF4_cD%QXFaeQ z*@ocVvU`Xd5EpZ>ZvqU-{6+hJ2 z>l9NH)!5v=*PL>0KRX;~=*sV4`XEDkd`C1}o;&H6DT#jq4eqDTa0Lo@?liRkKk&8lZRF1%suq8pKncJLN2Vj%Pqh2FG6@bvzbOjK*T4c|n#RM1tfm=?SPE==&|k zhZ#b{UCSW;AF~mH7Zz=TLV20BW@AT={e4XrA}kxoUJNlL@B;fd#E{OfWPgPi2HJK) z`|%?lmqp7h86HIiu1G3bd{nHQKP6Xw-oXiEp89{aK3IF zPQYIAJ6pd1}g~1~^}Q z@b5b{Womm4NtP%*Ijp zZaGG2e9m=G!jpuW&

Pe7CAi(or%mU_X+AwXM(Hz)Pcs`0WUX%u8=Sl;wCuTzj z{(UyiYzU0z5nBi`aWX1H)=y;UPh(s`aG%-uw}xgz99^&;0xaUn{!elCtN1>Q$CZPQ z22hTSGP9AI1Zhq1s#I^*bJsG^d4JPPNgKHR4u1gWTgXY+7Gu1$#8yUl_Gzdy6!Wh= z8|d<0YRL0=U&ce?az9L!A%qh_^NL1}ADqw*^Xw0^p>@FCL+ZGXbaH#mVYAWvBMYz? zB6$y%YB9vJk~Ib+vql*beZS6!1c8AL-j~3`=TAD%2DH|Nu+>opi?BLh5$lCj)$ETb zLpaN}7~=RCw$x&Xwmp?D-{}LXCP(F)wWn}S8~vRPFXLH2$V%%sG?t=kpuQeLQn#NY z5|TfxB*;5?5}BP0SCH@j-~{I$FT#cRfuCbgi#YNJ+7&9Nc=P%knojjvhs1@N zPY`ad$6~vn5Y`}ti{~mtaT-QL8Z5Eyy`!{G5&_NWt*%nszBL<3aYzerVxy$aTL`~8 zEoxX+7*E0Iq_6*Ui7p#U!TsvABW6>74(CT2NVZinTG1eH>{BwZ%Wq z8KI0Tia-G{^D~@0pds6ab9?^yr;g>4bC)&_nVDGS3qYaTB z)8A7kxec7tw$kmBVy<8iD~&d!`6Q{OMt%l}pp>LN%qzwa5LsFB=s=h*NO7>?60 z^j}VAgJTRK+7|!`Qxnb^-FsaZ%~r=4K6T%92wrU*tWRr0SI<6G&{+7=Ao5?9%lK3e zKUUb<;9btx-UZSO zvDcQ1?7Y^_`Y^LG0#~v2M$0MdU+hu*gh@#G*5`!|t8o2*Ov><`0W!$MvsY$)Vhugo zod6OXJakQ7mkE`f{z4%Nq{;_@c8?J9xbcn_1ljB06p(!rYcTth{huOHA(=0Tq$&zt zR@m8CL#$_iT(5@PRFq|O9~K;ENcMRKzb?#a5ah6l2!_TGuBn~8^Wz@iM)dFGw=3}< zj!L)WV3d_?>-w@IaRy7bkV9ECFkXY>1sCw&2^A2UOKNe2U;bA(Tm7BEVk0xxMs#*{ z4X3yeug9F<+>P7*Bs#qTVH_$Mk9RH1>_@>hPn}J$$MgI$Vmoz^$oqqn-oVhR9qAQ+ z4{zMum{12lME#?z_5@(o+^n(JAJf|Fo89d7$xx?aF$(#=;dTw5dS1iZX5Ga(zFV5W zgpm@Ldpi&nkR%T8+r;nJ+|Fb5OG~@{6S>ql0II{A!^*t1(Q@$`5mynq2amx?< zg;?z`?*{V$Qwj!Sxq+pAGT__=+(vxg(Fv)k;EZFT@2W!j479HCCm=8Z%!a&blw;DN z2;v0QhC^AWkr{%GiNc^0$dABHF7pv~XD7%MGXRBWYC;dr6GE4*hDFIWgr_3D@RlxV zgTTRa+z>m``LZRO4PhcNTDk~_EpKC(BmA>L@y{AezFA|yl}Ob|vjIsSM8l7>jJ8-E ztYWj;V$r>lt!#_Q{0mmm*3c6#&vu4DJ+9zn0?=E-+O;#Z39kC-QSOn3kv<6z96z^1 z@wsN>{#~4@AA7%@p|!0tzO)T2x;c{_f_pq4E0c;Bvb1l(^D3S?@|$+Fo|Y zw`+fb+9_g^+4u$ETK?tXgeLTHxR)C|MkZLIn{%6qhL#&#E|WWX#bgHK>;_DTUFI_T zF>0arOHgF>muT7*6Ly=q?Ue{;)7u+TIv3%KnrH<^C81!w@>3%1Vp1_K4F;$i+a;>! zr`P*%rVlz8g2uXZImA+2N0=!S{k!x<cds3_2Dd5 zyMTQq)q^+uI9=s&kHXU(3>NnEG|AUScJf@pB&b_ykX}twxj3!6>XdKN1Oz}C4cBiN z_)UL7MT>9_vjK6-Dtuc(rqzh^%Tkx$dpOmh2Vm?J;+DGNi?<|*uoBm zwjn}@Iu+)b)Rl7PP_vOtxvuYK2RaxAht%RubfqVJ)OcaA8h>Ipn>N%V%7)F8c=Y>Q z08ZU%0xh(Ku4vhteNvYuJ!StW!rEYq;ASiXqF-V>OyS>J^%gHJo1&2}SRw{AE&+$x zftj~n6Y3B_e;=&N*uHF+wIPL<4*nD#v@?gq;978^blT&$X z>PYNJj!Dyg$dtIB}b~q%NaoqQCYc&8=h30kt`dMV`iRQw2@N5H+>&a$!GKAQw?luoMZ|7$e;!TwxNhKSUVBU|K z-fJGd^B%4ma3WO+q^b>l;0}kEYrL)7&w=Zo0jZybn5lzMa@dSAZuGL>?f|t`WAofwq>lpaXN~rg%m>u0SNW`?E zyP7mJ@-*D=#+*KOY9X4(L^p9+6^MgsjX8Roz4i&w ze&+?res7Gyz6$oo#0jg;{gg=Wbx|rOT!bwpCA<$-&OczE4Sg9I!xelBV8jwzB9Mf_ z8y~bB{OO~`e9#&X(*-$b;Y3}1L-YSQd-M1xtLyRqd1ipjWD*EuB?(MIRI;dutPx@+ zi9!-o5TaO75}>UKXcci`QJ;j^3P!6_(NWMQfYm&MQISxRYRhI@6uVGb`_V2W;4+iJ zC1sl@fb)BwXJ#Ps@$>n9UcWz*d7k^+<=k`6Irp4%&%O65wtV#)Z^i zRfW=iOG`;-Fv!C#E< zoZ)jB-kgbg{zWtO-1=(@1Cs{pJ2h)+&_7Qx@>Iuu{|t_B9NnDGl?sdH{>Dnx(NxPt z$vra_BeuBwU{6l0ZnN&X)vhJXsq8{F4c&EX@la){ow>+2SLs{>ac*H!usN4+3*4a? z1@%^U&veC*$;5}=>q8JC%Q|i3vo%of7{$78%P0A1Km&e6+lcznGGypG1yj-z)g z9AT^^wOZ!}5rhX?0rppoHwCXMSK8YfZw|IJss}diwH(;`DC@V@TPHU&4XD$Us$VwV z8hqfJP`hruIk=CNvop+G5mA4kq}R@i`m*m3^`!k@Mg1Ym|3%baDCxDZsE_o}Ej_gT z*|oyiNe~0vo{#${GDY(J3@a~Izt+_2O{i5 zue^zJn;PeK93kH)Xh2a+JunK#KbR7kwd+KvYJkRBs_l)p1Z9r)QNe4CLxZczl-}*7 zOa+%HRV`ff(F&SdK=Vecr)#%LrIAz4j5VZNxMr zMJu>EQh|;2{nr|A3_6)XU3es=vIYD~Wm4BB>XLfmDDR^#-xS4ok(pO$qa{_Ta?s&u zop}pc>(_IZ>&3S#y% z9J`@shJ3dLtdlXuV8voJYiWgQ+usA*-wYcr02{?4l-@kbqt+I_7wcN_KKm&|C#m?? z@`LY>bQR`q)NEd9tUP<)Gdc9uk6h0dif2fCi^3<>wKBdhBB*4n*Yo=h-vk5ZkTLG- zNR#}rPS?+(KrW!C0;K;Aq$fa@N~a`K0&Ga(BfJly^zI|3oG>hFZDe^eO#wv*0&M+ zn)fU3$0uTtO8&+1FST( zhDPFOB-#-*pP^wW&rE?EpcgyCDcBaki#3R2BV!jO4q>~@2tB-)XQH}OGA5H2C8phx zu|5gLgw3K2b-~jU0K%lD(P3J;5YSzyiIG;N{$ zlQF3JaKgXP1t8AC3o9giswz!-PG#kT>i;6ZN+(!WPL=m>0eC7@6;Ixe1}YPKMqi_& zU~5KhU2Sk;-Qr+RwNQO*pe!4Lgf;vKl;asJX~T9M))5_BMqj1-?F>L2Ga#FES%ZQD?YPb2wzEa=%4qq8w7_&Gh&%WmxO{T(5q+G2fpw|$FT zhLIWu)Tw3$R@)HAG;&@@Cw+cSNlOvU0g!6iSj)jB(< z?d0&Yki{18S<#lPBZCVBHI3E-w@G7-vx9aV6jG6IlzoG4ogX~Kjgt$`u`2m;2P@m}CttAxJ%5-|)&BQ;bwQa^O%A+=B(ufanpA8~bb2$-?-0~U)bgnS7DQ2$Mv3R!ehc3x(*b$6 z@m|IINoKYF`O59e_8Ul#G&*}eZ3zZ}xxx{p7 z7rh)v<7$W$QS2MO0L8I1Ccj(MF#EJ{uWnqTr7Ph1V+q#$PE`rCSl3T|&F(T<@F_~x z>yST3@n^W;G#4t}U?r~U4^PeyYHEBSp65-7{axh!BW0dgp!Bwo?{&)TM&SJmur@Ie z_h#d^;8~`TupxDRG6Iv{?yMgl9BVu9a3YfxkDArDCdFcq=Y{?8{$sAXv9w?!noA{dtMp_4#1~(1fRkUqX*khFijSAMxP?ItB zMYG9scnn^yMhzbtgxSYr=tIgMpIEMceQ$-{`2c~Qa%e1kB(uBtN4{pPk1?hO*DID% zfux-%W)h7n4UUT2RWvax+*m!rSpYiBdH9p1A+EI3t@DB`D<^#q+V~a?XhH;EhqkAI z!v5oR@IRsL<&v2p`L5wOcr3I%gEHR*bOZK~02a$}mclf4e!GV4D)M50eL)Al#E0&!bN?g$061N&5GgRx33^w?;D+=2S;b>J+E@^^&Bqr%h^>F+7_q#3r6TYV6~E~ zyZy(+(|`&Z!}t^R?>-tDtRJ0$6WFSB%95G?tC6}tM5cSMDXb|$?!?<}5C=}rx{(P- z9jb9uEQV#aLjSAwLe@#1KAK_aJi5WvS#x83QC7pABDtP36pGgu;@CU64JB?;n*Q~& z;rh|XY=xl=)d)HH8^>RP#=kKtrUwN!+Y5^i4(Jef=kY13@kOzUaz*eUik2wQegKs` z3cv`J7eAb+7eAb=hu$EKhJDM*3w;3OFD?mJzQtY``p1A3@7=Dn3pbqRPn>w-oC$4? z)kiTje(f~lgy%VcgSPIH*bPUwRO(BZazt=H{;%cR@{y#AvopPc&92VMl2mU2%W@9J zvtX!V1i;wDJVp01bS@;YaEfc7{5DSYay~=BW#lPy83k}lfC)UmZGgTQ{i#PV3y`Og z>YD!+cy>yOF#!k5DH4JyuF0q9{Rw)%+^UT4d91w9X=U>v(F(CxZ`GF(PJ}im!*PA` z2eVR`iJFM~e{IF$P)ZGK!SJsQ_d5h_CSTL8R1WeV5l(`L$()Y9k zZBA9F=AeRB-oMbr{R-{A3I7&_d_a5(Kt)MkbPFa6O1KXo{Qz6JKxq%cj^%X1p6Z(1 zk#n_PjN$eHrV3V`X=BNI3GD1&R9=|RzU~4wwsOe`O~Hwlzoaf*smfP(-CBuHtC|9L zVnBPZQpU@3!*F_w=~?lBQk8CXt?;F-=}zQ0AZ%t=9VQrCHKA z{xPu{!xjQ}Bby3;7#r$Lu%Xejn+cDMpu@z*uc;st^bE(*mIQWUQB58okAJh$*)vx$ z()oUp)%|TkK$EUnQJkzS-9#g~*DLMjoddCoyTYy1sdu6nOM8$f-=wcE3>;S0^|i{7AYF2BoVVC z>6iRg^ZOycgs>#t3?TFP-HCE$r_tDW^r(2{Knm$S5S<_5OCRc5ew${Sb0hiuW9I7R zu&&tDjf<2j+!#$Re#fzjmB=t?gOsHzlU09A-l_G2a^uxuD1D&(moy^i?~k3yG}6ED z?fuH`o~l%FXjZQ(@s1{!r0(B|UA39vk#RnAb@@T#k%T$N*;4f(bq5(>Y zB&vozTo)--)ljauBAmD)>Z*YjKb3#BYkAKwS4Y%$)iX+bS2pjhxFRm(1{Z|*HN@4I zGhG*dhb`OgUCTrzevc@}sJF9Rf;L6xG!X*k_@zMMBFQA`X;N8|*M*Ys-fxs%IUd`E zK468NKH&DNC-QPQaoXvv{5X`*w4!c#rQOlwLg@G#%M0x_%6K0FtI}$lQ4V|B?C+)9 zb$dpSwx+N`&D-)atlBfW#Ng7lrk>3eh5pf+YQ&~16WJD&bS#7pa0;IUXxn%YG$O^6 zjaK(xTwKYA`@xThmWD;a&qE~VA&&jb9dGhYtU~$b-)1j-t^Yf7Kr8P)fGfXM8Skf2 z-KI?P17y#t^1?iAgfd%%Os;)jy8prQ!Yl;Q6#HmEe3*OiZJa|h)9@Vf`d_UmG>1f0 zW{WOxgQR`eFy*MmbiPH!umt!&^@vPNT38l$UnBmGTY}LHzL|>gn3*B+9Rc#K@96&Q zXE-;rb(e;}SFmiA*-vz*NQ64>tXA|v=UG+v2V!-RsMFCo%W>HDXb^#nh0uW6TWFw$ zsrK=TC=J+~>Y#yqXdw4toGtxnfP68~fS?<#=^VN%v-hnp2jzk_-y{}Yz?NX?@UBpm zKZ5!JU_*vo^I7fttI4=pR-DNc8m$^iEsp{JxV7uUEX4E+ zMGG|x0ZT^|yho3hMLTewhzp>r=MX-Effz8b#;#}tnj&B>m{MyFg{oNpNOIzqwofsq z-Oda1xc(igB4jNoiWMO!A;2SaWDn_4j)|sk!V&-92~!JG=DvyNDf2hdTY4X@Aj)!^ za6SL`3L@plD^)_(!Wk^WO!U1`QD|eQYE*B$P-iUOsu-rusG&3#ZyZ=CAqk>9+Ojwh z;J|>6mjFUERCy0Y6v^jF6D)|M9Tb{=&fJI+()G7c)wA?k8V)*eR{=zjJ&nHV8}wt) zOjibK_$oAEj2zMXn=Of^lTUN1X@LrSl(K4Uk4eLglxi;ECgZBOia#-#r7Ui!KCYp^8~mn z>4a|?mQvUcs#SG0THT*rjcNQK7{J@xzmhbxmLR%(%1w%%biKu_AV@E=r4lL=J%1$6 zQL{e^y!csO17I6Qi7!OB9eFv8osGblY&ds)_`hX#T)_Na)sB`!lpJ@(%X z5Xs3+DGQ`gHM}$#sl-Yp-BfUTS(z>i@O$uc`%uAT0e7c$cyQ%HoqdY2y^VAq6H=|Ltz#pI zLHP-lbXzkC=E(4Vj3c9c(JFn1^|oO1AZ2S83o;_S)`3Zh*uSvOa?<4c6MUDpWMz0V zla|&A6cSz_PKJpjq+^VXU!>@hK&`BE=i*q9BhO?TVp2C(rteYR_;C#B^)KnM*<0rP z4;g;}_&pX}Mer;f0tNuwswOuHK=SPnDBmcKh9DX^Udp9CJ>uut* zw#G$#)^k{_fLSXbRS^WjKM&wnUIgBv6~8YK;Ml>e*}g#V3CZvGD(hYb$Kv{xF*yf$ z*cWa3JKq*yp=d#0?(xv}mjS$$_I#1J3EI-mLH{P1rLt38xFo%Es)>OL23&eLhh5kA zn_(tl2_+}D{et|b0IGfn)b}0Z^CI76d;}wcsjhxw^sIa{$4KKtzd?FRKJ*`?eq*#o zzA;8?j)%fgKK;h1fp0&jcTp+yFz=~!MgXfl9oqhHzKNSfUv|r6VgAK<{VPiB`HMB& z{uK>&Z{h9b2gm>GmV;AK(AY6xQspT&Ey?a((MIc>%YXJhR;K9H++TrzElibb{GshL z$&&{m$;7~%o-d>?R4wn4KDDU)VBy0FbH<;oId}?rK7ssQNIVIy(Mqw4@3}}>w-c?P z6}}N)*~#jm1lJ_!$DUD)w>iB?4E~zm?>7T$Mhyr%&)05O*0~@qiRY|<78Dv12_;U# zYk8MAy72emW}1sM2{8)iOY%8kul)C)@hTAn)Y^M<$5-t7{BUk}gtY z6Jqe@3}xN304fA1vr_?;NN>aM>E6-2XR&l3KHKoQ1Ib?T8{3%O!fBkB41tS7KA9n! zB1Yf@GEC&p%)o?jPl7m#W&1;l_q6#uz-@47mW+KaTmH=muYO~nLD@_Alf~1E_{-+c zH_nB_5XZ3)9-R>9yaSkVP;PBj-`>a ztA3>~!Y31oaUBR*T?mdL2T*VR+{d_|q!HW35;Jc5w*O+YC%U#1FGF^Ua&CLeTL1og z3RHSJ#%|;;bp`#K2fE*%xa%)njdpinoT?e(LC)=b1T>P{EAU`cM0r6m92<&%r^l%*2OFB*^ElExk>Tc8DLj2jc<(XPRg z;U$wv_bKacr6*k{nMc7+7BP)^jio_j5)Qp7DD+H=U8#*l{&9nh1}f|Vqrs`f$$~W- zo{cTc&qryXZn13x4g#YsU8#GE@{`2{uQg0PMh`aP zS(e@(r{NP+7{$W(BUsq=7aC%s7mcKZ#*?s01RJL8cq%@jo61G2^-aP6WO_Z$)Ow;! zHI7YJjqQ{@hL5jn1Ackwh-fw28jF0i+odrFoS2TbS%#+)YWEa& z{i!{Qy95`=GKQyiP-0Uh>0R6QQpt2f8t+0lStd2i?%IIA765W-wE55gLx|ay9q(2L z5`g1Z+GAOEwnMO@CgolfyFu$I8|%Jl;{bOL;`qAD%Jg~JE~7GAbuS^}uK<9aEZp6? zq(nK;re-v~t`2TG#B92$;pV^TFP0$RivU)@IYZ$9Vo7bqo7Tx(Z<0;>nXIy%rV$ri2tC zYCSxHIbo1_g{N;$T&FAzi#!ln(8Umy!m>$(3H#<;7nTKMrWx-(!tpOjI1eOn8(HuU z?{@+GOO#bUQSJ`Ty{SaPfvxokXi`wVAL$sP$0GScllU9Q^AvuI?WPcuJ;|~UK?Io} zzs)fmO3kzB09+M-B4k%UJ;;V1kND$3lD z*W5 z?ClaJF7389VuuXhOgf+uQs(!*$SkdvzW zswuOBGK(aA5b4!zzBhPZLi*?=-G4W~%jDZYzU?mRiUFw6i3DO(r$^EUv~_Dk_~3cB z$d{@bzD6KwvbG&g-CfkZfwV-@9-}u;S=tU~P+YcXZUl^FYe%QEaFS>0go`V~X3q7~>tXy!Psku&&le0V5iQu^Z006{+a z{{1TID625`v6=SF^a_KmRPD*1t~}@~uE4ghFoJU`jJ}y#g*m?)FYcd}*dPZ?%aR4)`}pk^wRf3?Z3@H^WP`QZ z()k@kb1vP5fU8B6ufguJ#&oP|9ozH5+HJ(DU33v+sJ!2@Mq^3de9%=NPI zO9G!75I}>eOenqq01iD&4?(vRbRUPk#OPS-S?~i#qL#N4JCn|LkA`HNkO7EWq{O(} zt_aUvCo&j?e#~Wx7Lb`u9#J(+2BS<@xw39KP1WOF39Qz*_=NOE0TsS&H-zVUi?g!s3Itgc=f>(U6vZ&e&SNS%FnIj0Z7*6>x zLifK)`WDhxy;~&gFKq97HB*tK0lz z;QoRVL$rID_0z%3Dh9!i`OyP~h(;iM@}&mD2;&F?2_p$Z31bO^omo_>3%3c^3HJ#X z3O5Q@3U>;Zx?UqbZ~7o3kP2W7LvYz3BaL$wx)S#m1y+%sD8TM5@|VRJ_HLWorcE;f z8tFqy415o5nscbqd)PtdMiL#U6ig?9$M}mWF}_4;3N-O&Eit~H#P3u5Vf}SY;rAK- zPO*Ekm|qWn$Jo76#_tCHupW=j;J2ASOv*22^1GS8&t1k>bNGFgKg`hHxr12n-ond2 zM4RXL4gRo6k5msbK3z1(`0S2B#^*~08Q3V35%bs_P0XfVoG>nxNQ2QWyh>dhqfHy@2IQEEb!z9LWF@M+c z*MG)O1+C~YZ`w+d-5*&pJf2#|y|#9ij@YBxSxM*;JrHTr=Y1>ZP6uEZFR4VFs2 zQ=^hijX@-kgs{lw77=kjyd1G6mMs13)@blBg$?G*<0#{&P$_+P#~8-47~{l)F~-SN zF~%{CccwO@k+jERj9}T+!n(;+l5<}nahyvx5?Fp5twPjW;~T` zMBI-sh*MR1SErY1E6bsWYaU{rgGEomssi~bI3N{Jj+v9)`d6n1)d$N?5YKMRJq-vIzr*FuM0k>{Ud=`3c4f_%@kXld|2-inzbMm_|KQSl=2<#Vf^GlX zoGZX!+9HUBc8 zyLguFUqoW|&3a%7h3D}HVfyAMMlSOsF}+@24$tmEZeIp&4~(zwjJ@bXn7U3(7${k^ zM0K~~whY6e*Ys#n_fwA+9e(D~qCc}))HkQJQ^FByMA@5H+L@b*DV$ni^vo;G={%lT zVZ67x{Be#FzOjcWk37}&R;8Nq_>#e~fttMXzP-tw^t&7rwK#NSCf%kx$=Aew#21BS z|Bf1Cj}0h!WqtNUrK%j?r++_gkh z)Uga%cZ4M>(bN7mBg;H{VwCZI!gh<6(R9_yRUO+GmKLTgU)3R3EIdza%lB&UpPc3x z-lSNaAAXHP=ySM6Vnlm*=4z5~bUe>}G$Ok&zvB5b(#7L{l;=-*mKaf=DLFkPw4VaO0gjwBd$Lthzb zy@{kDC&pr#Dp}X}Z59P_2x&~T;e0dblS~nKDIBCT=?>+9MJ21GFKbkV)@Qka(145z z)4?hw_|whzF3v$v z;Rr!_UrvRQo~^W>9vxO5C9GGDCCDJmR~B?RZ33myX&zP3mxG%7kR_?<9U5Nm3>W(6 zNIc7y%qAPbG}5pi$u^Iu+)4nE3aUxljdT^wRAWs`fY#|@E9S5tbyu|0ES50+_-*}* zr%o1)RqD5?nfoRGzHv&`+u}^3jc(2CL5BA)f1pR!?*&)0@p%)OMFvQ?p(yO#vJi&=92e9+sN9a4EbqwD> zYcFkOnRRIWGiBEogtuRHQ#Q{w)6_V64)bju;mYf_rm`>pGtOQrYj zl2U!~T9gqfbEdmSURvHOENzMifcU&Bwm@ZVQusxbyK|9TE-@Lm@&i@j4+qZfYt zdM;_FQAUgToyQ-_?pHJTt>$~#d_6Q%HNINJ^AghTmbBaT&>f_$;&~Zq4@%l?dPqY^ zHSyet1NZC4RO9qhn3>Pu!|>qM+d$YrGup^zrqox7*xW)u_91a8(FoJ zzu!}F+2BHP$ek_-d2Xb~Sv5-gJ^Y#rYy7st_ol!7A1-4U@3KI!5!PEEYy(#$ti;Oq zgQnbvtM|p?;mJSi*taDVngG@INSC7UxW=EIYcpZQw&wDzQU3Y2zxFHxE$id&RN znIsmEny>pgVOu5%U#J|7$C)3ix(wgxa-))b;*su|q!Pt31yqZofI?v_!SbgOc^9Xf z+Mr)$8^82xKw}kaqhKWI8*-{`>v}{_#OgUscu9rZ)ls?I)giI4qD||KLpwBmv-Lp!Sf#_uuf!O( zHs07#G1k@LTV(0_*iI&G!v~|+X7N%gGcII`fT4DY;S4V4OTCk>}VQP!@ zZ&T~sO2^{e3PBFTgA0gDt|2B_dRJ1WxHnPo1!g9H4k~w~SJInYm1_zCdMB>yR_m~i zylqNHHi`$ko_Kb3yb8emJ0y!i;SOn;rbD)GdALV1FOTZitAiAbvSK&x8sX`dz19O= zx68rcuoi#`XOwc4k-9)}f6Wp8{tW0TrhJ89G?-CpoN+QU!g_gjSLdN&%2K^CrNcjj zoBXUP!B!0j{X-y-R1zA8b@&qr9RT9IacYN{UeEWih}SrqAt~wTrlB6Q=c*wMKKn7p zVIC%=gea|Ym7rUIQ2?T1tkTh!VT%GrJ;fE69t>h*(Cu(y94| zSnw;sn+}U{Jdx*AixJGQ0JX(v&F3VVtLYgt*hZdRPvL5$5esRucD|j;W$b5xrKk`q zL4y&x@h>XvT>O(5x2<<($Ewvag%6%ojM~rC&I$ni3mDc;TbC*yhqNk=6-?@duMZ0l zOhV&0U#j<2moeHB)6~1!Wn5wz)YRLI25TAE)Vo2n(g97q$byS4)}~%$!$lTLQ*R^X z=~0e6ui}}BYb?(qOG-IS?KBPgn5;6BU4|SZl`-69$Zp2+E-l7=e~efXvH3V$JIfa- z6Gas&PhsNVY#9mlIZTfvw8&1n&RR+CFI>7Amx3f?h}!Rkc1~g)pBfXkjtx|?%%6nX z%{*{Nk2N*_AvD={hHI_rU9guaR$01?n%=|3^k&i#)BBgJu@S+LnBetTGGc;f zVuF{64ZfzI4Zgg;cW_o1~ivrY6fJh^Jv46jG&mk6C_!5n9=dX}to48OITjoNZU za};fR5GJybOwg&b2UE}{CQd)^g6Uis3Hk&|L5(;RqG;>{;uAFb=zuVjbWDZal5 z>P~>RZzvNsi%N_P#*|>|_Dsf9>XsoHz<0?ndBtHd?`){u0J|r_Zgh)+VD>baJp*Pt z$=`xxjh6Y0ciDj^G}(>k?$zZem^(=jAI=0Slua-fcI5L1m34pPcrjG#rlQ6tg=8If!GJ&x)v-^T&Wvv{{QMwUQZ z@SDE};1U78J+cI9E}dJ$Q7V7r+wdUeHRkW;Z&;MW%n}ELt( zB3uJTL*_3;YIC~KRBj9>7_e`lm~GR0r?6dmN~k@Tong-c zy=Xdn`TZ{=eC$|RO0>aL?`kml?i&CK_C#CqEr~76buqlq$}xt^znA#eaRXP-G1k%O z>A!v?nO7CsMkqHr|7Bge57{8;zq=YuC{>lKUD{;`u+f^5N|dd?g(+kWH5y|vYa_0I z1YRbN?)4CA7c?TqXB_}G)61?2Y&WC6uv0S4yF^wb$59{q{lB%!b9|Yi@5~v;IV|J# zR&9c5avhFQ_3uM)252v)vKBKmb&A_BWB=H6LuvT!RJGxEQ@I+V|Z( z-SBIeW>)l&WjxanedU{##F&BG=U=f?= z|CS9Jk#HDG=N8872q?P*RQ{V^4w_AxdT9uBO59DR_fu%13m}6)ejIxFDp$f@lDu+` zoA{4E=e_<|s684mRNwagJp%aZp(D)eCN5(2_y{a1Y484iKmrQwo$f_qLQ}ciwefBu z7mdIcSyU$DeH@DJg0e&71{viM8j#)a<#WSFn~7)RF!_M1shb_7;scgAJ1P6D#kS=H z<%Ga}P`gb{_Pn8SHB2TI{fXX*o-cux%^3aCfFGih2!0>zVP`2z_H}37jNGSi56*)8 zx(SmBX>d!WRJLEr)v{$s>XQa!7!K1Bg#gRr3}a^$AilL)GPeXt^NkzinNja>YEugH zV2I=Rzk~R{ePvS${l`YE*aq$9!-V3)=z$5PKPct-QO5DXI6W|vALcO^4WMbu-Hfap zJwTsLJh5CSUGVcRK`U#nhrvY>L~vD)Gv)~m*kqK#ik6ISnin0%WQsL}==dg;7@h~X zY=t#J4h}V!>7U|Vxc=Atev0gqN~O^SAi5iz0ECGvX`Ew)n<{b8NJkLVRZf)^i0fD~ zH1U`{*_q7|nNmo4CaN9eKW3Weu?EK_`Fyuii6Ho<$bdFJYb$f z4V>L%4ospsFt$>q>YrdU><>$9l7T6q(_IY40`csSS2{0NKBozLx7CC@Un_%2=PT*t zUI;{|4(90trIF6x!*4h6%0%H?RoW@fX~5P(CpFAdlUF8?Y5fr4o3I!f%lmT3S_;II zS?wiihk|ElxbN?w?Yq&VS|h8jHO%kZm_5e9U#-k0esniBE(I2js_0uI7)IX`s+E(a zd|#n)veIR%yMMATNf|W0Rmjt$HnOT3T}>4a_2x3`ftWl-Z_9iRCL8N+iCNXQ5uM)> zk4%QeBs59tRT_Rg)jx@6+Eg2U!m(^fxhcFOgp?BYDZ+|=R@;QM&al$vXW35_n7*0( z&fpIc6O#7NVO7n4%tZ(S@erIs|A|<-V0$BgNL++0***$j*__$d339q*$+irY3e5M9 zg^(DCXgox85wzP%=))E|KBGjAPccIvqM;AbPzZmHc0L9o8v789g^0#LoQr+fK_!c* zED{6p?YM{j@ess4u!JkI5dH_rzY5VC344%BAEIl{X4r!!aSsT5iH#8ZO#E*W6Jah6 zH8Ib#SkOq%TQJSc3E>$kWAPxmR@}uR!mj%j?wP@)0&73CLwD8^eK0gE6AeacIM~aI zX-+;_&TTpUO;uMDUXiEXEz%A@Ly!et$u8U>-MB^$KlW(R8k3kg2@mh&V-yN5;~FxJ z0*$PaKlW~sXfonVkU$Ly%s9-Nau#$e^1AFmrm0X^aGh-@5b#lnE+V~tM`pr;H@Q9b zR46r*4t>U{pc5ZJuX~6LH$lWEfwGV!zS`XoumtuBVM(OuU#sCdrqyyb-HR7<2|(ra zgsSX4q4wiEKf|BI-I`%boPN_yZ$<9OTydP=8PHZUVM#*Nk}hFN?`)#Z0v4IeXEWzEPJNAvUnVee$t?eCibzlUGQG*8Qz1n{H8v&}5g zLtSQh@f*mLB05)(@MB;~p^xb$il)VPf}*me=hUroSzMseIfNy49f2E;U>$=!5sVv| z4gPhB%=2mHJpZ9Qf36rP6Ybq|ZsRtgG}N#pFYA5l))2-RM4sT9*Cxv{eh|ST@mcUb2?kj1eju-&Mx0 z6&4d4|KDSkbu~1;7D1{vCI>~#-xhwyuag`+Obo4UsQsKf{`bV3NbIGs-!3G#5V2s= zr%|yJrLkV-6Kl5DrY3TZPjWyS^P3znfn7a`O?fB_*#i~#&*?$U5M}&2FtcV*Qj=h( z16-^@C%|s?@(N4~u zmJB+OK1R<_v&WlGMJkr~xusO@12a8fOO{81U@r;adZ@gjE_d>A%7#^}G_TE?WbS8e zW&237bh6)&C~A_{^h0_iYLSn^+%D22g{2o^-cR!W9ha-{ey$V-Z9k&%MgXk?1E+X? zjz0mwoN1v6dA^#V$oewGjY1I`0PtG{Xy~YQA6Sae)J^x{fGz<|;oiw{tC`-b$asR@ zt%j#1-d)Ara>>t?b#k1J5)o~^X+NA2`0o{CeyIW^qLOFI`)Lxe5=q}GJXIg6$?R0nv9{aYIF?6-JZ|i=*ag;k>CHqO&jF~>7GzM#^-=#D~kZT8(>#P z>Jd9pJSCD>PQ8=g7wA-O%p|f9 zUcWwrc(c!@PqQJ${R8^#k0~`(;d~ZrDhDc_-@hFADO=dm?Sd)_gsi1M^y=IT^Q+Nlz&K??Di0K zM+ileP$bEa&F`4Vb94|#McxGn&c0iBDeprf4UUa`i&9PVV9viJv_K4%T*CdkP;F)H zdt4-6e{Vq<0GbQAic%9^-$GBCLgFd@ZsAXWF`=g6%``0QVIsb|a^~rx7e#k!IiNTz zd3#OfFh};Dc2$;zVr=i)DPNwJlPk$S} zcut-rELz@ctjYw>oliM(W51az6qxLF{Lb_9ryLWndkQs21Vs1b_Y+Flqj1SmW!)qT zUg>$^4hcE7M%Lz9BWrUFz}sb2+`>}-HtXY5r(X(;+@mw7n5$>A26*zHcI02vN!u+b zN@9DZ0Aq5NQka+^a3J^t#R2Km6}Y=v8|Rxxu%NQCo>Fa|&z3lr)nr-jyu_t{ znDShD1!t>Da8Ln2-j=*;^5d5)`cySIaX;yC19ug^n4t`dwJYudd~`1kA3jdL$EiW{ z_68^32~O&->SBGT`U;RR#AWM1ts$&h4i=>MA?`cN<){cd-)($HZsXB{F$OBzg){ZN z^EjGRvgRc#;e{F? zMBukeEKGFo=)ZG99?zSvJF+f!QB*dXeY^qwBd2iFg}FZ!X=`Y{JLGQvmV z;Fsk@IHk%q>pToKoNN|_xCIz$=bJs5(O$EV4{Xbn4QuP=!{TrkYdB>rT40@|KY%F$ z1Iu>T8V(MVovu>m7#$UWn{qstKkFEIQ8951HWk7h?pj$7b4NQQPW0Tb1t8Vysd?5h z+`gLC9(rHJkozM+Mk&S^SG5R|MAwowqIE3H!S^jlAMr2HKwqR!VdJCdiJJXe^AGjAX=PR_OHo)>S5wRx_1&M}Ki!y2A*qz)fLZaJi{ z->Hn|PS`xp*5@1r>PXMOp5unGp`M{0#}tdQ)pLW#G2hZN%JaI%kz)B|l;?LI$JK-W zI!gBVdj9yWQJ!y} zcPzB5degJ;1xKZ&_D!jFl;^sa9JbkzcY7W*c9O15P;L7~+He_cDoswP4kx&m6u%q+mJGiFQ;8Bx|qzZ0tU-#BC64Ec1eE^C0)?o5FW?O5bXw zZy!C#ZeuQCjF92+nOrz9w~U){OZC1xN<1_F6hCmlpix|R;`&p3T9pB+WQU`GG0OyF zcwm<~^(1?XBHa{_zSYf06Vgp-=F9A`fS*M-KX{obimTDmJeM{*rds}%?HSBzqw1eL z4b70oyPmBrjzszHQs3~XEhN1hPI9$4%GD=5Yg-(n)kaCmQCE8Y#!rg3DtyDeFmv?& zVA~qc;Ej%)%Ss2B`#WkD4&buGuzU#O!8BQcv=hv+A`gE37FXxI?3usOQK??*(Kk9q z$4)U}PV~O?mgnP*j$4P1;{Cf0q|EeOzsZpko0uI+6-HfP_56FIBg6CTCdX)tlI{7; zCda6hQ(57U--7V3o=3Len1*?8sR}di6mDm#{f1G3Qfe$V_ownctR(!t$>w(RUL!#> zLF-bP{i8iH+a-V|`p!KX=a~JHNS!`K*)DbEa$0A!js;LrrD)aC*!z*j*ss@KLt_v0 zYpjOG7SOnKxkn<&S{}(>a zf{%YQC&I@chxu0>nq*ERon#}H6C-a>=BtJh02A2JXsgV*etg5bTkR{7BE^t&WkFr$>6ct<3Ms=XiQrsc-2V&mf&U z4euK1nW#H{XxTU0^K+f{w%zFYSa)1<`OjHBP2b4oP7oa?j-9$Fa)R{wN8XzyzngYv zBy8kry=5CcmwO$fvM(74(WNk~B4W8~D|%l^6#pYVOTCWK1Cr=M7y)m39anJCZMWCq zOz9iJIl4EO>L(~H&VgM&(ex*GdQ!Fkq<@6xl~)nNi!`Kt)9v^92v>4qvTSsi@=*7ufwdm;}N~X>RI^)%Ymx^M(#{_FP{SvK3DY6 z5ZsuAnNKHOj$&E`P*Jz`eLU|Pj7ys(J8_4}{ex?MBSfFZ`Jm}usRK`@9;6OGWqlJ% zSFm2WbPt3q=f{d2DXXoUc^7{LX_~qGx|#?gADVerqvBSR?M+X~k_T1`cHY(&0)94g zY1Afe{*wDRKOv9UhiTb##QKQ75o^hY8P$43IZ#gLIu1RgZ)&_f=yWJsH!@j@RrBEE ziY|*mwH8izCa(k)hC7W5EEs{9BU!hLmD-gf4WxQ^-(~vTd$#~>^c(|W#qnT401?~g z2LO5wH~@IrOi%LmW?{X@Jj|iuw{<|a6Kj5=aDZNTMM{7GuMoP~Qi{{R@;J2W0Dk0FVR#5*!=`6f^%W&*g79Qm%@Xp=#VU)rYxOW2rBy zjb+yvN79^}dmW163>3vm6yftX%4SiQcqgsTdamB)m^k{&%=0IF2)0mH&b9eD-$bre zysmgo)yHIObzA^cn|q7igiEJ5a_LVcT6!H9{>W)-k1O_o!Y0@{IhqT2X;vG#@I)!0A-ON?GWyEDP zDw^A$su<-sg!9w^O`nNVf>S+CSqBhxX4SVZQVxW2*sV50>1_TXk*H-Qh1C+RB&!dJ z*LIk9cZ?CH7}>!_G4GZum5wcq^E+a(e!W+^I*8{n99Y^Mo@pMsfrbGl=YYxCVDd&V zsm;>Q+&vR?-k^u91KlFZ{8T8FRWSPmG6;!=+V8Z>eJgPRR-dYDoKaObkn8_0wrv1O79b_ON4K03t!%z<0a%91cplfh- z)xFBP-CWW(fm?f4z;E+7AT$WlCCD}%(iP7FC)+JmB--z{;q4wcB< zkal8=PtsUiD5uOK-EmkcIRnGrVpf^%L zFIdI-QOeR&!`7~UkxSR)+T&PsL=>*nuEyU^73Fme%v!&?e(EEnJqSN|{-fx^d(|w) z)i9`sxh_@Y^O00;hJ)`Upz4zXp!*bNiDATSin8u*3Zw1Hh6v+Mp7oqn`r1v3euihy zJ@aDIw8xN^#~@zWf?mS`iN#!?x(oajyOiFI;Hq{t!VACu{xk_rSK8l8V%rn8V>cc% z2}pOKug8d3l%WRbwb0s2TL%vn;Ex%;m8}1p{;TS#qxHSeNdJxoM&Cdg$ zM-oePoIsuXJQW>WZT|_(=((@Magq94&kH16_bXOS1kgBuk$G^58oysU@ZRxI)m>*x z4!({k_|wI48@{3gAv$0~Z||`V_R4`_IlE&HSm1Ptp!%nv?7G(+Sr-=)T{u;7an!Gc zW8hTNq_4(-8iXW}mQ~t-gsnEuldm~OYbnU2z07HAti$&^8wk^by2OE=0<}fc_J9eo zP|OQelaTyWRnOkQjSTvISTYY{g%ISrzA)T$gawo;eV1}snj5uo{`3ga+HG~CAW6_45mxgG^g}@N~ zO6qMX$Vjl@xzNO|)pv(Wmof6^l;}x7eiHTKJ|;*(+2pZn*1K2_kmm}kGFy%xo|9|iM`=6p8Slhh{4%>>UpW2<*DzZ0PrwjTm+VlLy+q=bf5@h|Q-NHd z4>4i#*D2KOD^R&|&+ZLCy?Ew(U&K4##e#LC)wQB!uMn3k4JYM4jU>}^9_HY7&d5ZD z*FcC*9I4ipGjj%;nHer++?t68uV)OJyq{5_HlF{AQcUBW$Sr#wIX6)POuC7nn*@f*waRWA9> z1|)u8k9@0%d{ZLdM0t2M@=fwdIgztcu7zI-;?CdqUsNfVsm#+nO--_s{2F(zIQ;-7cBE%%W z|A~FGn%HA}X}|OH3%K6c39kJt8P{FxTF#=&fpXOzD9DBF+1|YttG%+T5g`?8p5v7N ziTRwfWC8tgqC$3Gq*Udy^dyK~dnrekXQSO^voY=>&kY|iXd|gWh+6dC-z-9z$O*@w zcXP0R0NwZxMEsY6qcy0c)+A*G``ME6a$R~gH0S4#nF1DbDme;Wc6td=lK)Z`d58d6 zQBV@+UCfE&wb3%rX9e5F3$Ar_TtCz-qj?uUY@2f@rxE^vMx?hkC~9*ay=8Ke!grpX zbuceW(Q6;B`PpUgz8z(vsvpq9`vF}-YX@-cREr=o~6EM zdv=cVijHI(#<<{9QiuBTh?kS|J{vSu1wc%DGBl02pFIZ8DX#nMFtUq8a~N*zy9N0h!; zZ#UbVfhF;sZ35*Wi6)8yZD%^N zGe+X#J-ezDb4oSMDZYE=?s1GTN89u09!FC0h0T6JRTDgIdmPT8!g@kUcR~3Q5R>P* z{(xi5P-(;gW~33Dth;(j{z&vlQ@&{JiC#avBBwQ#B7yp6{VsT-^puO_ke1>(B)!KP z2a^uYU&49TG5|A1q>Wo#632F~Sa1Ay^yx>?LC#%2sgrw!!Tarvn3YoNmCnB#rO=Kk}1Dt8vGV1Ad_K~b+i zhj~0~gN5huOTPIbJ|fhgF77HqU~lc>%Ab0;A^%AA@-Ehk{Z5Ql+1F*QFWSxA-DTZX zBy&+8b8m~ad8!{EC0{M(j3|Xp2~4DXi}ldd<)p0~tQa-eOrEbx;xc2+>E#dU3Gc4= zTs|spl=l4*1gbw|n^QS8Vc2~M1DbNt;j0^5OGTD^a-?zCT; zhds}8TJq+>i05Kjz;I*EXofrNWa zBLqS+#M^_DzJPpRD^iAi!9DG=Dep=1oE3+6C+OUVIM9Qk1jmxQCg*WkMHaoUOb$0D z4P6##DE^z!!~mM7gj%%qYv{>HJ8@_Ep2&A5T00Z4HiH>h01(Zvm07v1FfRWP6jhCk zu`_?!kujCX7_qXCf=XuWcG-_@uUj&?&Z3MLjL1>`va+*?JThlhpsw1PvnuJK3}F%J zk7HNGA*?F^Rs=@?EFE=lb(7y3$C=?uQk=>mJk|oQptFfey((ApSh)e33NGh+6BWcq zppF8)Q-G$iCkx~A6FX;o6LOh`6H!-539N{BY+D5wP&SxB1 zS{?`aU&>{ragg8%`W}$}bEjy}GbJ5}ghGj7@w2tm3}Jx}T2WK$hOx044@m7GD2@Eo zG25AG#=#UiXDQ2_352x(k(dBI2xa*m3p9V?T}WG?iwB~>5SP{|`kiBdvf^)i`~x54 zqu@rKE&N6c2uVwK;>jzOzaOdWeef#5Ve%_w#1JdySp2=rS~eUkcI-@XV90(|aSO<| zUHr1V?XFZfwNx$^ylY^oXUieSwb~D3m89^Ob02~`arrI6W=Ah_iwmz9r%DO*Dm8AR zrr0F4DfdjtK|7;W$@#0UC$%;kbeg?ZYs*rbro4$prMIqua2-n}3xmSGfkfgs9} zz>vx$gLQxOvF3D5aFz1}II{(EFK$lHjo>S*KfcuJ;7b(j9XGVF&bB(G@5M?3v=OYr7vxoNa z-Yzw{`G!V6A-e1=txNyPi%_f7NsnXGZ2@G3P-lN!v%ZATnQ zBMSS~5N*7?Ha#H^oa>&g-7IM_RJ2$S~`E65w3IoS4$rf<=CPJ_n{)q~~a?jAuu@oQr z#F4e5_EX2D>Y_LZz7~ILJValIzx8pRn`tTVm~fsw@MTU@y1X~>?T?6s=6+oH1zaib zubJtCmxxFd@$m&^!{X#g5hotU=Z=e|%{)($&#`&OZ9R?-OXWMnN1k2vYyHez6+PZ! zE_a8lY37miA~(uLA}W{+gxZUuyhpb>Er0xo(|BbKhwh>Q10+EB>aVe75r`uqZv9 zxsTV?V@^Ms`&gYXx4Q1V+$ZYfd4WAPu4a0DHiq%~NApR)Dr$jq_St&!@s{v>N*)J?$+d&fU z3L5=BzpvukA7k|$w-TSPI(-EeOV5H_7r+haIjY*-^8s$%#3$>bCvFBn3whyGUmQSf zr>wNOkoqwb-E|#S-QsIE8;I+<8fc>@ZLX_S6`vHWQdM7}-RZ03vejk$uT-r*QU4zU zqcXLZD7HiUnYznZM&*$-3>NUF)QaY|1|Vc0MbukPdxPs_;(>vOAt7VE$92?4VH2G^ zP}o70!^jeB1)k%GhBb)M5FNp%7WdNWI!gk#yd}6*i_>RO%KBKpz67u#BdmgpD?@Z& z2xe%}gb~Mhl_82xEZ9zbnpWj|eFY@0v$~YFeOO49ArJZ<;3|C+d_)r%wp-{oGA;rJ%bvtNzdLCMUSdEwS!3s@3g^ zsC>~!<5KR#-Y|ngHrOrIY5vC+8RNlwJ zKSb|4sw#`-sqWO(DMQ_B;UNs9;!wh4b@TVab}cdPR}&tuTVCn(R6w+8N_@`=>Xa!% zdtARl;pBJb<4#Y!~cOkz1i=I3|ulSLlC>VhvQ6+cMWQkP~?Di-|2>A5pu zb6sh|!*%Wicb&Z3l2BVWVVJwF9=h;@=3eURbmnZm!tA8@-)4JqEf1y2QgpGD)5!fO zfTStbo-B*9$UmO6tinh*oR+#ENgyQ&H>2@nDJm?;T!TShruxSMdmzsKDqxTTy1 z{#EW)gf@7NyY(|S!A2EU#)Q_(Jyus*-yr6u_q5{IIRTTXu*AdN;!P%Dt$EO+3U_M- zb#OO9rEo7ndy!I|YB?L4a*o>}fvqOr2y?s!c!N`oa7R9QKXsW3vuF|*vVcm+;H+##2oQ1vI&O=CxUitked#a+oL7c)vHqkPNcl94@d%QgV= ziN@d7&n5NqvB~AEVPTQZUjv~fymo~n%JPc0)TZv^xh%cuA_hPvZFd*E<61l|ULD@yCEc58yyiUiEua!X%2 z!BrDc6SJZfWRax@;$Ez{TJtj~C*nMvav7At!5Qpj`WrBs%JjDiJ!uFK5CaV}eS#NO`3v1K6C5 zrPNGU|*MOL`FwA>Ml^{Vitpq&&Lotby@VlC`74PF} zG}kp~u4^ZuL1&^lqv5VYnJi<2aK|uGJU)sRYl==L@mN+Zx_eBI7u`Oj#)rk_qr#sx zQ;hboFOcVJd0z%U-;5Z28NT0!a9EQP?OuoKy&7?<(Lp()Gl4emL{nML?w;Euf^+z7 z1=02D(4LQJS9HH36)ooDtVaN1sHuy;GZtBm5E%!=VhRvJlF2BJ%f^G}V;RkHbMv88 zWHF+Mcz0b0V5Ts#q48l6Tb>~5v{Esi5w10bhpVm(CN&Yj{4yX@gz`Jfm0G6rLXl2k z79G3{@v051=*`-eD$gg56F{PRbmrT~JRo~xd*)C;ra@j@K_jm+QsFkY z@jEuYs;)ZTCA*f2+W5y2z14L}{6nUKv7dSic-Klf3!@wY>P2ZNL(WU*^$cnSWj>^H zc_w;;i@}AINZlMYE5#f&4|?Xy3(&W=Usj7SaiO6ICS-H)4k#5_EgOtzl!A8TbUM2p z!nJ&bjCL9FxsrFX;aG-^0QH|AJ%T}llf1xMD~0F+6zd>{ZZDVOkt#H z1)YQ4ub6UD_b}}UX7z!ciUqggU^y+!ST+)^n&N3=bl;WFs6*%E^KAH<0#F@9&J{Dl zhZ&&@pk0ixRsdrDGbhbClU_CF%)b5s%BH1y2yw?%{W}-wOe)C^MrD3FfUuQDk-dX< zDypxX(LMsB`wgT0y@|b~p3fO=8btd^*3*nSsYl|p#xvT7DEA1X74!A^cQ$&gdm>cZ z#>d>+K&AWm6!Q^T{Gr*fr3b|@xSY?2Sr38Egr0T0uQivarN>W{Qil;%pBGH2PhCDB zr~|IQ0L(A!HjZk!71_sGk3P`1w>G-eAW2jg${nB=9_Yy%83w>bCFtvvhe8)`X zT=5Lmb9Fzk(y7>$psmF{30&F0)Ex!rPtO4z>#fSY=8UWInlrMa_A>+UpPpmM$c9fD z!EvCMhMhpXmnA5Elk`ZZ?>JB@kzs{0Ucv6e$6_h#%ZMWk3tLYow4-CIZ_lbmzF5p9 zSO`%{**^+#vq6<(1ldChU#OG~Y;|~G2M8$^T8x&;9lWq7+1&7VUpJ!_`cR;aEV!MR zOA1JZf7R#u&YX!g2pxM*CL_2es2@&Y`Svg!aY@nc*d)#UHlux!PZ`qG0@`0D$oYLf z4VZ%W@_A>L>XG)=(w0p39kii)3sp}yj7J)7X*eb+#_glwHGITy zE(Qki;)Oeo`Buj(ct=8OJnR2p)ZYwR{~wQf05~yv4*<8O#p&^}bq=u|7mK9;Df_-d zY{A`X4BB%TQ_&`-V%)D#{wlNl^>L=&UXRit+o)ViRch~<(3DppAgoitm+eY$VWHxA z6*Mm3vppAgZ$ApJnWK6Hg@V0TVu?5gV_9JDH;mqRC0F}(Y=q|aMZ~(}Hr&w{yTPcM zq)pcUHLU!vk+OXdQeFc7JNXRE+!0ovzCCx|nTE=FXO=^AGKJs8%vDr)h0lDZ`XxRl zVputTR?a@|c6^nf`XWApm<4mbLClNxe~cI_h;t4nV(w?19ZWaiLww> zbX;X+7m+dO#VbAe6Z#j!y#B>t(rEu;5C`v3W+E;=+8rGq;}%6|KA$~|S{B0sfS#b- z>RQys4>t|G$Fx2!D}ZUcnvoD+;@t<7??C)AmD{OwJAiEA6T9;EK5gaNzCEn04hVd$ z5Wm@x_N|UOdG}kH{`({1Mkh<8biqRsRjhc6m>5yB+l|6Cv57|c<#D#0Ysgf3nwV|^ zBBp$fSp5PA+KADvMJvu0AJ$2WxqQKhXDY{7-P?`5aoS9y!J!S&*K)lvhanf*l%zw$ zIkX^dWodWS7a?QtyFU_avB_d{ccZFJU>-&5-cFc7B}T>?o{RJ5F^t)pNxk_*rP}tm zsAk@aHAAoe6s4EN5h2Vc;D@}_X#R9 z##Yw#uz!JbwmBmTe9nlyFXBAt5iTljTJd#nfvc9nx-sFlq4+Ld1F#}*gcy$@tKw*T zKJQ+k{k;H`X0fB>DyVs=a(nq9o4^bbHcALjfs)v>oovW5vY)1iyvRWKNS>hKgoC6w zK4jB61GX!p%Vb0{y7gdS@CY-kithnA`bqf6DNYsWk8p82u1yIxmUcx3Jr1_qOiS&P zpzDc#bY-3gT^RtlDU2?kI6eehxyd&7T1IoMA2T9bgiYHntEoGRGK5{YtsHEXgDtTs zrVqlFNJ=qwgS9sHDmW`Z*Qb!WpiB1Em(ut;UW$U#4nSq>15|7Tf;vH)6>K4FOl-+@ z*+qzS8P%o~n_HgCsS_}A$i&cj2DFWGGAOVT*V{>;!;iZ{xZ%D<%t@y>Q!TEtkX0h8 zES@h%DM6!kk~Ufw@?Snc{upOZ&A*bjk}Kc}Vy@;8do{a17|l|9U;mO=G4k6>f?Zwh zcbCOJhLMAVULbk=HK%9ZE9Z4*>Hl+omTD_aV|U_=udKWMJL@gJf4w!6d13Io8*qBc zdOP}(({qUj%kep*u^wlNqEbXrw_TY!cmZxfi*!R#uRu{R_fynAS%AJxn5BiHVp$^< zh_MEgg(Gw(Vy}R8B2*-nsB-(>7u(d@)KB$(lG7o#3?%_ql;S(qPjce<)@W#ts`QH4 z=~Am32M>mOOjyg*0-umwfhF49VTu)67NVG~j;l|mvgms&p^6Mtop#!hb!Z_>T?SY; zXdUW<7vfF*3$e(0{)Jd*ReWpAg_z1h9Az#<3vBDIZ!N@F_ZG1BDp-4^A8Y?$9qPij ze4CjULEFWw6*004+C&{aj$&ViCg=ifqU5_kn;+Zy@qP@+*2#u*CXBTNZ!QZ}uyzUU z3}9`dbxWPBHL*@tf;AQ@>XX761e4RSq055orE#(FiAK2>wAt7(3fg2rF8|*z$lmIY zLW}SDq<=l0Z*?UOT3)mJ7pZa4`?2Z9?K1T@cAEit=H6VoWXpBsDElMTk z({N4JR$AgtzurAro1|Kn4>iVIrd^Y~(wwAG9Hbu|o+O;8cN&$KY0VakV$7VXT^%VS z>#!P^Ox8+mS!9>I?4!_SM$J@hY*IA`(EsneV&s{y_$=OI%FbQsJ-UYF$G!bEz2v>OO7 zf}vl64kJrfELCL?V>ebR`Cp89a@dq(lGmFt2C(;(?{tUQ;7FtVIM$>mNI|^X;)OG2HH3R7A}I@3O4Tm3nxr@P9!0y zu1%nWatg+Sg^=h}jL<>{3LVJkg+*r|;FtGzAXpIgyBzD4U}G@*{U32(9rnv=-#Xmk#II^4b8VyS(L z|IvgsmT|VuZAm$_f2^{29tSN%6}Jvn#W_PPdWU5+G;w{3GNl^5yxxLde$|L)mZhSX z1IS9_r=zuLdO62hR}M>Zi)tRNB$}$Z!~uvidJX*ze)l@l-3869S-rZiKz7r}w{lDC ztYxda^Sdao6)J6O&>uxb_oJ)|`T4QXEJGD9U+v9LkKLQUn)>oiaB=a``}M;@kLjz9 z<%=qC2`YFwQH|wl>#X!uzWm>^gApsaybtldoNa!2MC&Y}$GsQga-{<)C#!ZXoy%%G ziuZtq*&Pwe0i8wMTCKEyMJL6~|e-+XUkD*S#hrlhR2mA>jF6H~YM zW9k@~(xswcKoqF-RiEacppynrB|PjHsPcoVJ3&>r5y8|@F!hp&se0}Ys7mJqFm1@_ zHubCI+bs4&%T@>S1^90U)|9_~nB6THv5=Q~G8lozY@jg78?tIfNYzG;No4iZj|rcB zSq%?a1GU(Qw=koeh8cHs$D|~$UF2{_6T`7)(FFI!oXNby679a-n0twKsr63FaO1v9 zw2itSg~^ip818qqr!z+NrBAA=OUSLQYoh!S&=-x6lnqEW|2?#o4)jAA`Mc_Hw-8A* zK}R->R)vh-!e=x4_g1k9@_Qd9{DBpi3Ev`km^4eW3ceuCyHbm9PA3?V-RG*;nXB+p z?Ldr|fTk`A9A(2OM=Fo=`&gc08n11jxSp~NC{&LLI#(adzf=|5;!vi*HS7J@vc6*g zxQA(}f=F8d{&Zk2QGo6^lO8I>CTOY5Z>NEF z@PBC$j$^(BDA{IbGy3IN&aVQ1+u=KLggr~c;!9ZWRN5BIRy-1{Ok1_@(w@}u1MmXM zH>^65ub>$s484l3xMpR7D!N>fX|=|h}#)e2MsHf^Rx7vK%S{+xC(E< z?J2H8%`N6urR#rLZEIabNxOsJMF~mh-1SK8bzOdId^lN8S|qezalF0<@s^qgGv+P-Q}xa%=aY> zb#I}g>xl(=k^D&!+JGz2qYmT`vajr!g@;E=kQa4o=kyv@eHoh=lW*+1O7ESA5*) z=*J(VXjGwR%Y(+{)3kBiwgmbW35r!sXQ_q7!xZ_Fn~VpiX(=&xv#3_eqT&jV`f1vz zJwlE;&oNnpRM@sf@R7)W^K6ngi_YaBdkvgc2DG1wB43?Z?6p4$pNV!G z%C@LY;LqhE_VxYgnwFU!DX+qJtc>J$I;S26n*5TDqJwd$%%NbON~;dHDDSM_^*F;RD3qz`C&o_&z9A! zvMHAxc+g(sA$^~`H(nkUA7BpBg3iwxQHHsQ!_f?$)2_WIkL(D`&4lsxkM2F(%~VT; z-#;=UJ}=gGXlbQVTVlPX*~wArH>%V6N=T2?#yB~)(1ruD*UzQVjaf>Y1d-W)A2RYf zmENVDO6@Wby_K>9ofaAyGqgm#RZJUE=)IV1#I*5kvqmvz>@21)LqKpoIwJo58uD2e zFn9dl>lqb4ILp-{=2Q;QkLG>g_h2G=^gved(G(MQAT`==3&04AM zOd%a8zm*S_J8kznw{}%aI{ehWpU8w$Irz9OUh*T9oC!Tycqa5p#!oIXCg*EKc~98l z{nfZnt1W!2K7WjBmh$SpS%EC2wy7-N6Ih|tHdvp@A2H1t+#3ukG?mam>NR?pHh$LzS%TyHEdi`U|G zEn4U%h92;snA(ge!#hjMQ>Pdo&eC!nS#M43j>3ruutj}SYHi8&usaU#yl}avrhh=C zm9}`B-DwnDt|ePi9x<-JTpO40GP5D9NuZV`_($Wh%eBes+s59@wKU83Hya(7Ys=L? z8gmM?;$i;ex7MLZEfQ9U9n|~4Jfp5an>H%xt%;R`9T2 zSzdYCnB>$hQI{LbIIo_WKjfQ5e;L19$&(|AFl3ELF1@+W89#SwOEWJp%MJHR{C^6+ zog@ZsH_zk^Coh8Hbv$RJ7HU7gX#G&u8>f3#MRNB7ckRgn>dGQ@F4_T%*k{jBf}6Ge{Eiww$ zV`Ev`VLj2X9<}y*#hG{A`%cf1vs}iCN--H$Wag1%R4YmCXlF7wJhCeqk53L3g12FD z8bm8Xw+9~YV#)KZbFX-`_a!o_yr<$IQ{xUvFw>J8*Yy=3B-arBy@v`m@o$3gwYjm+ zJboD=W1>UiQQ3z|A?c#!NK5hp)nnu8I5~Bwqc~-9V0|+k#-N*sdQimw7|>+{u+I-$ zJtY~Wm>Giavgfc;`{E9z?et-iDOF$4*S&+Yb(dc5>F(rva)Bp6;-(lX#W0$r45hXP zhMCPFKMBrHBbV&BoR$4e^NSIhD=%33plwjz+(!;cv98n}< zd6lhJZuJ1Uzczkso2qYufl zki2m#iU=(QILTch*iz9LwjBx?U912ix@a@mD}%H3g?7)&aORbOWu0tQMuR#!Oc{a;0U!h`k_P(~ldEziNwv}@0Qh+(9Y6Y3xI!T{yh6J+@+BT6?l8Pp zXp1ISbcPq(5T03BM1p|i%OuTXIMX29?ETGrYzsL6g`_CG&S_kFrItM8FL?upV#=@7 zwCi4-iE4soE9C{bZ1)|0D_OK}WM*$tJtAeC=m=W?EsbLwU63sZYHMPaWYIUSP-;IS zlcgNTmjAMKZa4N`sb%R0dQ#)b{%cEnAlk;E#LMPdl_>|{=7O=kfByAEnVap2u0@jQ zMtq0@u7~DIzXwBJq2x)~t_g}~;X>h)1%I$pW)IIdQI7YuIh1>wGJ)}2o=zDjWqx;r zYv)L0cxwI}qj*GI>IHeD;(s_q+4d&%B6RXY-bud=NSDzM8+9exjOe|iWu{Q=W*Gln zqNSv$sp0>{h0eU$bDViyS&HY2JR^FpHYR$wC5-lsTa8I`wahFLo8lYw53f%Z|JvG& zcL)T&D7q|PxvLzlrG&J^ch1!^)N-SCt~PvF1L*U`I`b~!#O)q6ZcWD5#=g1QQbPa2gNEgFZwJ z`O%$=v*l=_#aXc|gUSc9#N9DD8m{bHl>AM>;elHTuHpEk)> zd3B2)rXYR)am*;=_&jZ_{(f4uGUhMqR2Ni zkmyt5Lq=l_H}bF6MyRhDl~-%S)fnTaS8F#}-peyiU#*R?{Y5dy<}gmIj!QBoUZaf| z_n!GE*_%r@DoqbyF=-G+l}6)^YqV0!&-08ouF*2BUg{jVMw?^#@#Dt0`P!IU3){m~ z^jWR4?T%`ZmXhw0h~8?Y_Av?zg$KxMfZlT=LrbmAf$gG4V={You4%97;Zn1Co?WdNSY8v(7c(HOIk!Tzw(C|amFtf!_bhMB)H+jV8$ zDd3j0i38c9!vGRJXrw>P7eJsQfEow%x;W6q}vNOG{B zHtag7^)g&)El2F1u~XTh+`OSowXboJ0l}e^ee;_JZRv$U)r<#cG~H z%7C5D1=zmlmAslz`Qe{1eFfy?2$In2%N_gCgB^<_+b+6|Q>rH*1PAl{rJRLFvIYdVw?!_Tl^93s#>F9$fcq>zoeJZq@t?zIt zFaDmR(7atQJK(^Q6s^Y`cmdgFkMDcEZ<@vP>0}a6tr_C^{Mrc5=Ql*^o?Rcz@a(px z_AN0l`ziD_(eX+$S(USA?Rd1v*I@l~{xS<&q(sixMXSDB=~z%7VBQB%uYh_%yF;+_ zC$%D-`Tl!oFT$KP5;_sU4}3Z^w0k3vH&jK{KD3V{@sgn(T5z$%Jejw6y2*GTw(=(E zqKOq?@286#Fc4uZT*Mw5wH5(r^-Q6deb8*?Fp~wq;s>@IX7b!`V3gkt@|>BJ-wH70 z^v`4grdpZF-vgH4ssy2qHrZxELT&P2)<@KM^7(bL9(F9%n_H|)``#$K&-2UbrPF0m zG;dux-H*{m2DtK*(C*6z8iEGpjuIJbBaPg^cx537?JEI}l8+0IR|X`>Q{}#rfyt3P zGv%2mGt{$WD790N6S~Dzp9g0{{o7=y={QOR5aC@1K%lHnS*-&rwDas)bDK2hNtCYE zd!k1eqp#DFGXKkb9KOr0jo&UZi-dE1CKHN82H1LI(REs>emSW<1N}Kygh`~4*K#k& z4A4alvbdYB*-P~9Wj^PU-s{k>A6|rC!^}HY!_G<4XppVeeVuA|zwlYvbiGBX5Df(4 zX~UTJLLqBb^w4PD%MQ!WJDX}F_o2NfMYZdP8gY$5R zQ3z}}Y&IQze9`j$ckDy*P}epxqiBT5-n2=|w%5Rqs9rMAOv+VL|8qC-09H66&wbOG zb?eQ2HFBvJqT}@slJ57|wiNb_U>_(M8&ybZm)&^kdNg7Jv#!??)t?yudA)X%e$Cml z!xzw;=*iPP`Z0|Iu3*Q^)xg}Y2KFQ*=`#TkTg?cQ;}=itLz7QF@- zU2!knC1)w#srM*D*&e6=KCFl&dnesv?7KnBbi6@_ zBFAKb34Pq7D7)pqSk4FIVZ>P@Zi$wu4K)Xl=&it#Xtd7LQjA4Qv|*X{d${=z3<#Qc z!W7j0VlqydnyNxmd?_?k6O6N@U(RLih5*)<<@-dm(Dxz_?Q z8Lv=7Z8yHYNt=1$4zB-`c7Mt@N?l7DDH{El^;cr}&0%od*tjOc_}u3Jbbfu>9E#`zn&;mV%ab}J_D0Mw##_yW^H-uZ9pi`l}p0!JAODkz7nhO z5SLN(J#DF_^ykLUzo!-Hd6bg@56;@Ka*sw)0F@ zc{B-U9U0Njl)}Nwh=xWBUz~Fx2iVZY9^pd+*Hjt&Nc1T8b5CmU^apy7k(n}az-epur7?l`w zx;ULK>2l3_05Z7VCemYms~*5p&$zNo8#D55rT2D#OxwuGHQ}Wst93Ehv`XW_GHtki zJCBj8X%(c=WUSdPVZSg;7jRgL{&;JeIVwU9M$TU zhU8p(HcH+=BWd|q2(!zzQgyDezFfOl%{Cg#wecxrH98~!=^0#uC1WF{WLMDmbGhb7 z-UJREDzuwEmyV-*M6EWf4L2q()6!{T;W8~NqJ(K_GJK=rMj2a{X)i{nl7>~5LHy5* zqUG8nLr#+})A0WwmYYMCYX_o##ovZt=(U~yX1umSTNs z{g^VsVs6IkVVsE4#u#^fU%OoY+-j-KaiT`U@6YVF)XFXKH}UCs!s_`VOU(-$QuDex z(ULI@ijeX@N_fr5qP~VlRA19)L-;O4TT~<8{>!at)9)z|ErQtEPNjBz0k;r0M9x9|TGHpSUN?RFq}Ey9>YMrk z>lM96f5 zo8ASF4NO+@WWr{=CBGgBpWT+a3!J@j8g4~N=e%nEZ|bw@k+4sQiX&( zsGJTqU#KBX4kqBgXL~$jaBx~udZc{p3i4XZR5$KZYV**6Jl31d8N%2^O4ytGvd@Ng zc{9<%X+R>O(Rq5RKtf3bp%xj5&t%ZST=vXe%gB3|Kr|P5=GWqK6h(70V%W|-Y;t^~ zyBwEDY>b-)+?GSmc`0`l=R+RreLz1f=H^Sxobz_?d*qx?!|i_DAObU^e(7;?k%fdM z(HXQ(>O4?Mlk@kG57uYkQxB@OL9zNROF@LRMLbLN{O8wtcCy zr4x*P2=*Ezxx0+s-hmd|Kl;5(!wsdW!+Va>=uXIGFdF6gZ$A%bl9#$Y#ou7AeMuN| zHU9*2WBM_-Nx$(M)M=p3MAwqPi?06@w&s5)U%w1Whd4}hZT}n8gfX)p8_ED?)J5N5 z<{}o-?O>*5`4yh6)K#Y|yYdOAky|{(=pPE%JRv>O6S{$o`J+mUEE?H#G(vCBX5jNW zg8KgG6?&dr$NMZV#EEj(*KD4umo$4OY&3J}uOGI#Zky!OFUCPxOnm-joOrad@@S+1 z)XPSq!CIGpPx1D##fKbVzxF4HbU*77cUe4$6{BqWqr1CfvR>MQc}7Z~O34;(~5#`ZLh=cEF6 z(%A@&5T}f!lI-Rh0`lkmE)b&x9pvdF7oS%$_@${is|#nrkibUQQ25+!FiCRl1&E4& z8o;*Ux)=Z^=bQQd)^vaIRY*7I+dF=v=cH`1h)G}1e4pa}gp-L%P);YHAoik!I4)w3 zB%3fXhNNNmC}%F^T_ykJVw7hF5m@ZN=($HY4Zh(b$)aGP{n&T0poh^g7z=9px3N$} zTFIXI{aCzzQ&c%|h(N*8%vz=)2RQtMT`G(tbc@ZwN8% zdrGft_-xRXV|4ZGJh{}y)`P2yxO33H+Qtz}NtSqnQFsTYfpXLq8L$ibk^!rHn>WE+ zsRXABf|6+$niH+0L6{u_uB3h8;1Y;C_j?keKscy$ zd~=^IvhVc(h5o{eE(-j)zkn3}WB7Mpb_8Ykf0+LlvG2K9)k=Lxs6FaMYgVD+UZl1z zwW%@g#}ZnXUZuty+JjVjhjAanQDvmkAi)BoVM4qiZP>cN=Nx>O!1m6_ea zm1C$p7E%&M((nP%(yB90HWBqqot>=O{BP-^Lt!jA7dZd@Pjn<@%R9bZRx*f7S($b* z8?T{k9PLV7v}*l#cGlzDRF(of_+1SDBPsA#Ecf3Q_F;*37T*;H5c4Wv*Y40_WPFk- zv7jAOT8Y#`dc6^iwGMk_88#6GBWV*`b}`L$6G>5$Wq(Ez8-H~vj6C{6&elC&;u*D2 z^&}>6Iq`)NIVEyAaWsVZsI#Spl#n*oP7KCv4L`Y~+FJW*7RK-=S8F5mYgeu9Tdl_R zR6!}h0>+5&6n-~_3Aj=4I{vC0c@Bw32O^cJU05x}aMXccdS?nCazUbQvGt@Q)0#-u z(Dg!*$CjMv7O0@g+L>k>Xpyv`5Q65P;F>j;XA>qDdI}QAU>y5F_+=MLSCciZ`Gv{( zo*2Z$T1%Y!#R->te3#Dhc#6}e5Ad_!j}FVp0Q=cp@4?sP5(c423#TD4IX3qLCAK65 z$`hdMpu?6t!ku)r;u*b4^>|X0pahLKEZ3FV7L3J`H8pU9xsao?!B@b_NJfd%!{#pF zm_-#0?dos1UPj9@F-J=oTXK@yPQ#lR?Lgy%!_-#OY1l4CyB@XV*}pD7e5%@#7s!fk?wYS|Jb8l>6p9k%I#69?+H))6#KP%EamYkW zOK|`$5xas~iM%+)Z((*)Pxh68YaxK!uJo8~(4HLTYgn$-K0v)6_y5-K<QY_+=#jxm8;Gbu_4MFDD)z6PMPe=%6lGhLJXzG<#G9L6D89K_SftlF0@@hS zESYodbRru6kBcW|f1W5r!lrF4pe@m<}b1&(FkIU=bNa@cr(z zG3g#HTQBi^FctXYB1ll#eJ@Q+DbvF_NdTRdp%a57J&YASDg02ILrsjPF z5@m?410&5~yObbzg!2Eo-+Y5AQ843eR2)vQ-HS&1JzARnQ_yyj0EnE)_v95I(73&C zu|2LoH|pv)*ZHg2p21>PHq|U5mVXf%(MBQ&+9W0|bV$h?eHWag`WDfucS(PX>r>*z zOe>e}A+c+dIWF}1Va_;mZJij%Y9gIak!rlH01lI-DiIxb~%@T)#f+L}; zTFJZnA!S=K9vNwP<*jc{G+z0UmacP)!MD0RUH9!SKjY}a7awr0o-W1Ye_(KzFg3PE zyAFJ(N9j#^T||%RfX~&@ue#VAoJJxEL}HW?>wEnD$`8zo2bPO_;hQ@N_8_XV^(vAs)#n3(pboIY!C z7mvf3`0p`EmtM?2Ta=QJJ$Y*0e_@JU#%!4mUd&*_iGUSVM<`$=G#9c)8F$XoQrASG zPtx;&K~Tw>6{FG9Uxjvm0;S0BFTM=z8r*X%eMzE4S%h*hbnv@K-bowsZnvWBw)Ouu zq13%wQD8kI_W2Mbu_1Vvn~-u#9>CkPV6B zB6zlD68=>-uT9nwrb$Yid{tG2zRHx=Ssr4iov& z))}+Z%7^on%181WqVDrqDj&_SipC=tNroNkf95ZbRQZ0ux6Crcx5IkBZ-vF`+irc( zx7-rpyUkd2uQqKeu9(G}&;*`LL9io`ax`UkeGJN7in6$w;NAl3sGdgVX$GTCQ%CiX z)5z*Y)kiP~0)z8SG5sEo*tjag@vn%KH3EtBy_vbHfDGPgG}$4lU0H^9T9jI8^$+=8x0Q{qpIS6cRDUFO+iP3h|%1=~az@lyUX}ZCqSC9#bLS!bg;Czxt6;_nz!iWwU*?KIA5Kg461`E|GRZiWd9Zj-K;a- z2R-B+a;9#w_Q#dR0sddck9%gug<7&9h?ZaxU>|F zW0QZ|u4SzeT?wGqpYLVYn(%b|w32ahW5eH8e}9efXQ@8+d@o<`ufD2^>M`Ffp);F5 zUlUFr_c=P+Kw7}2`}aR%S5Ev+Jb>IBdT8hUR(8fV@|ey&lLK27c58=z>R0FVu?wIc zR|mrYzP@jNlV~@R69e|UtMPvI83`r|M=~dRA2^A#8f%~kM+y6xeIE~m@I;;cA-~!0 z@0P}&j#PrOsS<5eHV$}Nf}(G2Jx_;*b}}aO)?_n};Z!bh$a&}+?plJsOU%rD`{QcQ z6i3u{!D-!_Ih%W7(Z)CcImY3ec|6OBB?qopaUTo6y9O)ndn}S;^gXfNQhN~`DB%$< z4o$Dtk}li}A3TmV!iSAy>AHqm*g;x2r{Y=^%8JG0h;Y;6 zJnd{(yS85499ToZ$q~YNtKZE(xuQ7l^mR>H2s)|M`vz1w6f7A4yEUrLDaLq zqLtdYC`lW*A|-}*Ijf0}P*F12_?O(SEFLk_=9tSZDF@=vV+)kKTm&e61q?RuR;=N8s@}5}P#Y<<#IP}XI zCA!?)0%cgi!`%D_8E%SIrpWb$qKx=E<7)-=(V)J&bAjCDtM~3ju78y3RPAJdVY8OY5{WF*^&VAK2!q-d}qGvCaYT{8(Reg0d}>7w)!U$-o~9Z>lEWY6iH zWZ$f;%AXUGBd9`Vkzj?p2xf(s7>!h`1lU{vAUlj^KLsTBhz?=CV>+rs= zPFWs9nzviG*cYxATmQps~GNM3eLEdSm$9}kELw2FfVc%NPoK2Wg2Jhcy$%;coP~TL%q`8}r z&YT2i^J}c6lHQ?QAHy=W+8tf;ymx-H0J;evMRyk-LtQ;7kLUtV@!_eu`cPmHp9lP<)gh_s1CR=`b-r9IvBWK{>$F8hD?u4&B-H5a6L}P z8m43u(-1hR3s>X5MW7Va2H;M1;Y^^Z@Gx;2Fe70?!Y~IoU?uh+lwgk>DQqfowme=n zJMReqev<#SVC3}6YH$K8MB|FVF1H%gnRh#u+3qJ))6!f|;6qw8VI*0vh4|Cr2x*D_(t0!&cNMA!77kllYp?czgFz7$*n$?i${Q7f7>zOdqbFhfDk7?BZg8Fr6ry30XlFxl=aF3pc@|W7D zdnV-bI<6pOq=f6Q=PhD&yLjTeo)rPy#72*+16D&Cogd295d9ckk|oF_0kQp zHNwz4Mdm-SQ?TT0o}8uRO+`uvcB(CNC*y7vtnMsq$!6X~Ma>zC@xh7%2UeUto-nnS zN#IzTo2~9i0((3|n2@o7kIOa?~$pn(x_&*&d?X~ zlJ7RP!X*(Fxt5A$2;6~ouc6%l?WJ9f`+3ABkHI%eyO7-t`YXTicw;yTtjsAx%w9m)X$|p zO1oJfTF^yn7m4NPh+i7Oh@XC-)AWH#bMWBs-mG*JmNKR&0Q%mb?m*e(fwIf!K>TF_ zAO!mApfVa|1~B}UVEDL8pB3m-g9KQeYsgBWuB_`kUCfb~*BW`3N`&e_M_-QUM`ueo zE>JoW-C+iwuB=fW@qx)*xm)>`d6#(}m~){ElaAmOW+>yeXd(l#j3-YJZA`rFPN-f1 zo&HZ+nSYiP^wIfxVX))MtMi`aorHjkS*b-f!PO)CUcizOxVEsRtMfY3cN$FGR97!? zyDYOlT@5;!UjK30>O4V${_lMBs|S^u=cp!)fPhl@b(3t%Wv(Eq%c=OkDc>^&#X=k-6I|4sRNd45A- z*wEI#@v#4d!f%?CAsld#Tte3$V7rC(jj_XPLL*eD0)t zHQ%>|wP8hP%{LM6CQi~uu~LmI^mPBSFgSsTCUF&a6;{r=nx0~NOW(Q(0sozV@P6JnZBwSwi)Kp767KX9=FHevM06mJi0WpgcG4^usltNB>& zxq3A((NuEFY3Lw=NMySlUpYW<_*@#xhAq>8$$nSb!^@@tF*~qo#xsv|C@*IBM6BMa z_~pa|gHJ)^Kv0woOFkETauh^>dV)4T#WW)+Eq0Kkb@352-&Gj=MEUsbIOhiMNM&JJ zvXWbZUU&w8P7@C|j*1S`@fY3i_;alBU{ZGA_`98$I`NxdgY$1QK+s;ri`4xzPMIRV z3t?P)qz{x-o9f&s(E~a@xh}-`K}VD`@6a&k#-YZ=wc4nekKuQao&&f`OL{jk2f7I^VE8vbH1(2K7%?()-8HzmndT z=jT~TZ_4w(vyy(ryHIs9x0lQ@tQKvgE?BsOQOcAE}WGZlHa31D=xihTnATUPL2Ja9tL zGx_ac`ZrQ%uo_s&|I=W%itnAgui;yOOXO;9H+2p(E%Y_3_Fi5DRyc!o{VyNlll&u%Ht(v816xZnls2)gmI1CE zYCU*`v$=$tI9q!<89z8MH;Zyr<%@(63%;S)w&RA<^63hjrwa>SPXWHSA1N)J^z;Rk z+zKaNh|kTC^AYz#x= zEx~(O>@g8EKFSh2J=_xPxl(C~0b(we3HHE;bx>cz6m24QA0xdtT8(zwcs~z}_29dZ z-DnTy$TMK_sx|jbe+PoN4}0fT)=}$c?Av=Pfn2t=&z(UDkZlsa)@W>RBWO@Maja52Gwo=p!;7d}6zpRFZ^M(jNX{+S zGRvt!bBDVlzIO53M0{m!;G%bnr^$Mw-dDg5GBBPqBk<;dLeFxF;0^1N>1CX&s^s}@ zcyWM+3E%Fq^t+HwgcLZ4!EA}9GXap3)TUE-H*SqCfDz~LD1c1K z;VdjHk=V-?kJ7NHbGS~va%Aa1#}?J@Sn`LE@yj(EIAd^QACl`;KWOe7Ula_04G+On z;(6{$Q-XU%a|J8qV5O5OJPjsP01)Z49w8xwCWpqPUI{D54`2hzg#PJSLp+@UokJj} z0boF?)IwZd*=ep{GF^6=wUfHe#0P&kR>AXej8iiH=xX4GP*M6U5>E( z3ICFfe;>BF?8+0%fOHI~tNi@dbmqbF?ps}tP*1vtc?HP$p5^gp# zL-9<7$Qoic`gTO!OGA~|*dEFkYzPC&f}k=XE-#iSu~zj#bPkA6Mw|{&npgtRZ0vU8 z5k1Xw;R#c~n#m8qmhcC0{fndx=yWVEPg_TMO~POV2iUmTWhVioqJhdz;_hSUMwAsk z@%z6Qh$^ zk+#0!l48S=`jV~L({B)*a1TU932q0M$|LwLT z!yp>%Ki9Id<-PP7$F&S-mzqLTbesE%jPjd@Lzd;u^vOidn2|G~zFcA61_lS6ucQeeV9P^e;lf(^FU1-c`)KXX2k?#^uP#~H!$oUXu)M0Z5FJ_vm zXFHp9r;@vaDdNViT!kq*0CMe>R&OTDc`9^dgUvJG@F62WmFE+A?gsfsIr;vQYBip3 z)N+Pxr?NCwjRGG+kzY01=xEd&(HBuzwkdY2k+NNzO48^wZw?Iw|-$-N$?qsr#Y6Rg$9bnrh7OyvwQX6(=$x(4Pp^^eX+V*|fAV<0xe zUR3+VPY!XmaD=_BCS9p5)7X0RtZWEjt6-_%P-S{+Gpb zr5%_pOCmEma;$8GQn5^I0|jK7+{8ZLLhS4iVrK==2sVmYnMzxg)8cscFB=X=T;R;B zYOr{>r|;WZO=W#FQ$W0+HIF(Yof~sWIT&Ggwp2T<;qtSbc{Y5a)eTnfv+4V{&SsE; z{htDe^oP=9_Gh&l*FUR`(N_R;6>ZDrauZAG2Lt68;J*r2Cxa8XHc{tag)57G*~J?@ z&l14W4?{V950GyjznP(44+sZ0Ew)^xHg}TJwu7?Q_rE)ca3B0m+LTSCv|+lZ^{g#^aKIxBQe2S%6Bn1eW2IT@G{K`1V&N*dQ{902JY#6!BM z+-OG*bO4gGMRM)<)ue0m^_ETF^DAbS=UdQd*6=}dZ7Wc;9u@UR+RNr1nhf4w$Yf+I zF^8nBCMIGo^;w^mMcKy1ztHAg;Go}Ve6$njuS_^56uTNrLK)|bGK{Awa>c-3F+#6q zn^;EI8T^)IE?Jd?XH`#2j+cZ&9TMNPE__TkYw9=8K3!*wdtSRo-D&*ndF@)Y+UR{= z8^xJdWpM#m`8=y8wE(QVp5CNQQ8RaBNyRi8OZ1laH&%RMvLwATpWU?1xW}UvAxv64 zaNTUZD-`n{+bi&xe90x0kiDSbZW9;y17xM0)mmoli#5K8p zGcl8dii#8v+1133-mWPvUGgm5JeO`hFUw%V&YacoKKeZGzZiN!-|7C#toN!z-J3~Y zlorjIFNpMq@k;LwP8m$(pl||-hglfoD-K{)o19+xyNP#Qa4pEMNOU%Qb{Wffy|VYS zovr@Q){T3=PK`A#e~F{rtuJUct<#LhOZM8CRm1s`mL!igeenY?JhScegNIjeNUejF zVGin{Zw4j78mGC2_lv0FJ{h1;xt(JW0?c*>X#$j~R1}SEGcU2OQY_IO5awjX^)1+@ z0NdU1O50Z8JM0g`w>LpC3SQEZO#ll!iQW)%zJ&1N3F^s0tYi+LdCT92=#$Y44o6>P z&xtk~U(_zsqrG-CgztiFfpi7`xYMaTE=~ z2s&D+sNvbdb)Xxvn*xWuJ6&n37}r+U0E62@ovd_a@k2V5IG^fxh)VTR2}@^{RKi9# zWg?X(a&hyxeRT>BEU8q@X@rNV^Px4u@f>ybP-i7^_RmTkj3-l^)FB1d_s6}-9ev?C zouie-k5K0$Yoy~X>Kvg?HZcajrjCxewqnWxDlJgjelYIWb$%L?j*3Sqiyx)Z$JQvv zCsYbi=>Ug%K9-I~Qm33cYP=^&M43Qo{aBl9bb@8?++<(_3K z&Y?M7sd#AYzSNK4b=)Alx*V2rK9mz7H9*-zZWT+Vgap+yzu`_29AaRUxjtb{a8wR- z$R_zAI(&`}&rJ~>J~#HwRJj-Th!U|{5h;A0>1tHX4)xiz74F005Q}NJ4~ygb{e5IH z-7=lOppSD?N*`~H{dKC`PRp%`tNjr5`KEWkTKEb4i_}`2h=UiO>u-o-p4_Vx?r?~s zq`$)*5TDS(^K^KQ9_a9sv2UfGqQiPi)aq6`6x#SMX)NQpSosDzj6K&`gkvJV!+k|K zF7EHE*v3IIp`l%Lc8-qdY}>fsq<$__W=UGzK%?iQslq{>ofIpONe+>Ylye

  • f># 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 J=_bl4{x>L9zCZu~ delta 344312 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

    PGVw_)cYmt{5pEqqV&+rkBr<-g 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�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%0AMBaIVzjR6 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

    Z(IBP~y>vPjyemb3z9R)+am257ry(J z+KTIUY%=K}#J{#ut$REH&CWk$qW^vULrf%}(2yr)NE(O)ShA3(9*WOfdT2Jm$K)G5 zY9jz+Ws@tBq95+}0i2vH z2NqjPFTpg9N<{N;bF++NYhn#S>|R$nSa^4(x*8F}&a=L; zaxi=O8QQvulM{sw=h;hfayPN20FK{*56vv>$2eIu?Ogp}dMN~lQ7ycjCjUsrQT1_h z^5d^+7)_1>Mxd+5fGN3zCFa@Q`_lP+B*GqY*r6BK;Hu; zu$rmTUN)|$Y;tZo!g*yw5bY`V6>`rpTTeMU${Q3Gg2tsGbCL|G(5Q}kC)^O%6GAD) z9b>IM<-jpJa7m}e8y}p?$em)xJ}|@-ikluM0((_&0`00V<1{De;16DochHqP-cvz0 zX?WZio7D?a`+2sxm+b593)&_rFa*~qkZhZ|9=o#ak6yCbTS64AC#aq#5D{^~NaosG zj&L0e#0~?0TFQB_)ZTJ}JB;4E91Bz$By-tP1SefZN=oHmM=z$>zwIn+2EN-jE@dT~ zo3X5=w;baA)FBfMoFKTdZ~(`aMYle(kEzg3j1FZJYyKFwg_D6CSR^JWUiEQ&1D)EI z&XtWtM-eB02Rl{=TVOhUa}Aw)2M@{YZSYD{H>pFEc$QNV;k={{93p^^&yU0uln`WD z(bky~(q&!4HU)98dMu6a;=L|HNW#5*W%X!@?O?NOp#rIahmTaWTwXO8 z8~pduA$<`ZTPd6v06+YY=?q|?i@h#jO&OJgkBltH!T>-AzKTKM8$ec+IBnP=tmy}+ z9B-;yfQdLEt{iFMoGjK^d$|G&7#DFh0**CsNht1zSO|C@Re-6{)w&2@sPjLVC-~>n zp8Ya}CnDZ*9saOz-3S=msDal_ zS&3MNE@gR%m}42+k|?K##=yVeRN)y_$|(mA%AAZlB*1(Z1CD??hBNOZImTZH9?_oq zdx1Ah!gGa$(U zrh`-*#_u(lJvz)`w<4UzF18$6$p1DQ*H=zVUXM^JF|*sD**tUugds+YYE?&~g<%x1 zM^nCvwU0wDKSR??_whg=I*P}5M} zQ1fdmM65}M^3gsvQW_26MO$GgI2JNcuS|WOSJfTf?{m$s%@VAyzNE9VixbgaOMp_n zt{YT3ZuvN|qkzR&%R;!V8R(lx$O&fN*dX9qb|bcHhvL{a7iC;47N;iFLLC5A$($^- z7Y$adI$F;7a%D5IjNsk4t%(=kJi6vLY9G3D#Ec6#K}L(*}v@k{5LlG5ST2-mz5I|_j_k{ST2 z#V9xnph5?d&-qejb!z zKxay!DERb1>)q~Z;Z1p1D6T_|;j`*K49^F(eo*Ng)Y{!%>kAQ&SnrZety=S?Yi^mk*k#S;o8GK~mPKd3)zGG?T%6Vl5 zx^izq+KllP*>={=L4z)#_+5?LG#x z?bBf->7^!_TXl0na5?+!DTA!kMU~*RqLY}DUz1KA8DQXg>CL0dlQHu@ih;Fj_KIR8 zs2KrnRsY6OieHL28jy8pF{p5SZ_gb9SIlM`rKPYI+{~MZ@Y*LVEh!?`G+E?EU!O5V zEzHaZG*r=H4<|9-2j%R*Hoy==Kt3EXkO1yuqXPU8LHhu+)q-G1D{$M$-k+RzOy15F z(Bi%Q;G=E`LV-6x1H`^HVP1pfjeJi7&h@hyR;rMh_iLyJ@%f-K&FCgw!y8A{^f~sz z&tf=@LIxTxxo|j5>VGPHAPNrQLXHs&dbkjOibtL?8|^Is4t*PQ=-C}R)<`oA>^X;qQQg0ywf=;`+I+=my>(5p?t}#+apCfCyLNDs#$~tc(ZVgi znoUu7KeLEePDF79P>zx#E9|g6Xw6_ChQ*q_nPDP`)^$fKwND30BGrqCfdcIWq0$2- zrY>;>5Nf`MX~BSSVr!L8W!rK#Cm7 z2jpLv#I;&){03bmbGGb4*V( zWFsOj)f(kqK4FqOH%9{S`i&6WFcfKIHS}_`zRw3?<_XHLf0rwxv(X=bi#B{GSz|0Z zB`Oc_x*M$-c?D}e1d{cGxt5GyEG= zg+A%l8Q>L{`484?=B506Ue1S^g|Zp!eG@Mq+yvhTvLGH@)-)+G#XkG&0#6u{!j1kK zv(Mqssx|-Py=|Ac#CYN%@8gNW43tA!aqx|6SVGk40MS&V)P3Z?$dv9N0y=f?ghB zA8c^xChQW}&J8X;Io)n+-0p%-wi(g)qN|h-Vg86{XLjG9@M1LSSrmE>LVY0+F4`a* zQRkH|h|`TrBnFn3iVFOvU~ExdZQJ%5$}L{>baP#V=7+W}n=8X6Xw&gD2tL8kW)kpn z+#lcoykQTg0x$H*?SDH=Eua}Kw56~=wb1z2%un)j%F&YM3@H3*PI?5bhjaAgVTHWR zyf~fmJlzb>AY08nxTYe^S9PyiS}tNiVdjGx1S~GUa?q+dE_%2}!F7N$$oj#UkHxrG+Z3*hxC}M&#RM$n4EBZwWfa{Hx3tg&sH!vq zs-bi+7*O*>nJp`f3yf>)o>E~pq!F6-_MxVss<~h+PD5EKu3S!2RZq+oxJgUI=qJ%e z1qvkSu6RP?VSs1QN$uN7v;&JaaF566q@j@FJaYKx8CF!cVWXw&!dOcS#?+iAQNgZ2 z26w^qrtc!^bf+A&M>kv~BUt9A0i%b37|}rw19=}nX4rx7)R7plRb4?%fbj@m@Il-2s$Rg1;EVS|?>I#y%Xb_7( z&0b;T1F^{ce*?^j`u_m5!y96Uw*+tqo(o{ndX=^Nuf@6q|-c*mfc~L6Hs=qD*vKlr*&Ae9w?$}b{6YL@o zF~N4t0q|k~r$nmJ5Hu%hW@luEgjx>(y)k%G2mpwY5L5wx!4rVi5nBE_Af_RR76NjV zj}SCI{s2LBCkrCh@7Ua-E=}!-{%^*{*ejeR!5=i>1vlH&PsI^0L5J=JX{S;=C(Z zI_3f9S_AU-Hlk=+2gV^q#^?fMENQaCqAdr(4^FZ+UTNI!eaNXp2YQ%+_97B!0_75| zj;gvf_xWiUpkms9#_+kVFsV^ukt!0*U>6tn= zQdc8@IEC2t3Y^E0y`HFnS8^QpueZiUyh!d*oq9R$zup=*ak<{hWLfQ2#z)cxrISi>O3|sQ$xDKjAAm;Yhs^$Bc-jv zzb)J^^DDY)|F+hldWjS&Y_a|pR><6J=kGaZN@0(L2%zn)AIy^fyJz_Y(0{Zr)t@hR z4*jNZe*()^NWVblR-p158QU$3of`iqCfPKE%ll(;D0l)5$03Pu98q~>OQU>bC+$@| z-|TvUXAEz;EF~b$q?~{@`4**n7gW>2DwF)kGLY9~S$&i#6U05jf33lfOI429e~OG& zl`db&CS(wo0Sb>m)J5~Mh}T+La*xpTTGKBl4^Sd&*J9au6QOA>ClbCH%RRLmi_=hj zSKqVqm3^vVfS0YXC6Scl-To!rYyEcC{yo4E^7m3ss@YXa8t999f^ZGSh?cN^H zD?dSKxts#A^icxrM8FmxuLiK^rEvc#n1Vi9wg}XI>HSl-j)-u*o%j^}JJ#56(uAam zl~dA$+FTqB6Q07f`m3KT7l)v&dk73&r42VJ=F+3SH_n0XvQ^8_n$VgYNhO?yO#N{kNH2Y7CA`avp3i=6?i zUH?>8oxUA=&Vms8SzLE)N@_G)i8Q*|rp;B&tAC<@@AtNp@@%X3=bzA0+Pt_HRoDH?j|| z%2h%Q4Yp8QgA%GVZ2QKyRXXH`uJnF(z4PRaGv&~L%_#^&{`%ux1E@EJ0HRbb(;TVR zyffMJ0#?XpLK{*zU@Vnw%bgReayx258YGr;9$@T0N{*G=It=h#*gz_>xCOI|mAnXk zo);jFZw+*Mz@yI5ExBDjLN>yuHF$kd99epu^K9iXXG%@=WOXtPq}D8(+#Y^igEuSQ z*_CF~yrUDG-bu)WqlY>@o2EK@tOjjte26`zre<>0AoGn~qlY`Y#=>>u!RiMRoo5(v z*XU$tS6qnQgWyvPUZl*Z%JrBGnrFz=oQZU7p;2k(o94dCBwBO09Wn^2nO1$v)p8=gDw`=1nqk94AM> zCGoq0-)MeW{Aw&Y?P*oH>YR|K6O#;FIIhGe~V9m--G+pL(RGFembPWtc%24wF$({XDTegQJ>wAV3@x{tv0?y~Mc1PgEx(2{Re0_g ziw&GKR0ea!<_3yWs{T$N8mu09f@vmukaY%~Y$*Ca>s0l5U%ueGl|C1L@k%F9sbKxtmn}Jeg2}WO z813^5LhV-&L(x@`pHun`;<|u-CL1V_q$-E>Z9f7Iyb3gLn0B$ws23Sw>32JGU(Cfj z!yeoiFRRDALgDXKs0!c0A1E^N>#!9!n@9o_yY*gfaLQ8sQ;XP&XO{-cBS{`0SKOe z*!<0`{c8ZP#L}I{;w}87OGqn&;M*a!?(m6hRETj;oVp}AR#`L>;kEKsrBRHI&;hl! zh!L}ua^G`?_}Yd6ww#7IWt?bBP``apK}64ZZz@q_+W_|m!<9Klmlo#KTG|qW%eE{^ zDYnXeI-d3Vq zA%^S;`CM(re>A8UJI98N=M(-p|-s?#}3WYpLM}vs>dj@h*QeE(0=HE=FR4DWBq5^^p z7p=tzfvIQFI<#w53{$gVG(&AUD=fApE0J!3hN%cvLvhA=+tf9JBO9)BW}u#lZOWWF zz=cECHGy=U>%k4516&%n+q>Lib42I=(q6mATO^Uah{i-z!W|( zB71_Y4&mjpLZI=Ry+A?0`+MPftPpR2sg=RP8)o}g$H#ENuVHE*{%OGKE9j8mhsvF^ zI%WnDM5G)+k|V^j+ygXb6e^9dQ{7mFsjhp^be+96BOhv&7_1hu#&xn=gpHQvCQ~8P zcI2%vr>qjGS5}Hn7Z~JO#IV`a+cjvw4ARs&#js89$h~z7FWFls!bI1^HHlpmk}2tI z^_3i9T4Y&H4a_b4+|~Akb@kWhSn1wQ=ZBYk2rC}oO-E)3POl*Q z)zq^P1T310ZQrB@!5;BFu$y74ezEL>x?V$!>uWp^0&W3&7vN3?>>@^;0^~(hXwFA* z5jDz^*mc@H#S>`b1p z`0v=J#&$oU>KnP`z#88;pF`B;st`qo#3?E~k({UX7UVbg%*b!~`v#NUAs+IjHqEnXn$sK` zU7K*H;Jv}y z&nK@Q$hzM@=i#nxZwolMF+$w3PBQlgKKlIpN2?_rL=?N8AYD61CwjX9r^PDC1H_Ui z^U85;{yZvtj=bKPGaaQ1tXIuYd6A$g0bf0Z#dRLE)Gd~RjJ(_gvLFGqU}hJmM!PA= z_2@i*JnTV@;7jX5+T${EgOxfH*tj5|6 z%D!9>N4(tfd`&U?2c^g2%9a?iX474|zf%@H6FesoFpXQFI46KSWAqYPn`e`T5)=`hiBjks8Jh|%$G={Wm6 zO6D&s?W;ywZ7gGPk7Tqa=1)$*so#%-fHhb|OpYLf7@{A=5WRp8gLAEo^&6m5tjgB; zq>Gu*`<$a1)fjQ)uyvDu7~!dM>q zr;nDjtna3cBu&i2K)^BON7h*NBYJiUB|o`erLWKv=|MDUZV;qs_~MsV5``=bJp)}@ z)?YkK{Up44Fl9st2sV5(G+4e3=~thu?USZ3mk-WUX&9`pYcB)g8};09r7{uPP9}f1 z$NwQsKw4x@@^!%KYD0b!5W>*90L6G~&;y_nDX~GKQn1V{cpKh?$#=#cLd6x7_ zrX`X*eD0ZYp}iTkNtFUJpZzOyP?1c>?ST)af#Am#@Xd1grqJPM^>PueS(H18n@u9h z+hGb4)?#G+4#ejDSIU&9u@b-%3{~Dw_6x6>eA%2g-vf2@PVBwbE zi?KUK4^Oj!II@9z1XJXnuQ*o{&k{N7&x>)z^DZ3ApC9dt=3P8w!p~El8xb|~&&nzx zAo5mEF z3O=HM_!wRwGF~bXf}bKs^#m%F5gg+EhuGeq<$FdxRv&q^>j~;{+&jt2Wo&_(Q)#6^ ze-=Bogcx!)DlM_&c-1D-6=V|e0KCRCY>P6AD|(iHyW`z0Bch_sibkwz=K42bRe>vM zWFuDYX12RicU`|aUY2JhO6FCaW1!+?d?XW_LP7CMJjHttFcSZUf1=nfL*CPTC;_q4 z>HarMfA9Pah8179Z68|?HQ#RQJ~O-M3y;i>K`3blkHJ~j;?xPn+kk=~gdQS6al@lorb<9eQnLHO#_)u z32!(vwh$k@*hG?DK2tcNXSn9GhHI;lmrqh&qh66*JSgq0#-v(_@Y=I*aW?Yyq80`# zUg)n9_^?@N?bE9gYaF-B^du8H>`qb9#k8=a?}&C1hC z@t?OJTbAoN7^?Z@;B5Pog+K_%xoOl4NAn~hfv-Rr$NgAg z@0!Iv*+RJ4!D(9iF?CmNCgMv9KEl(CWF|6?=p_Ap40v^9s!IoHX8N)CPWh?H&>_F4WWp`2bIPM{P-4dSmZu6eN@EiW4kYh zi&$LOUsm9}EfuvSmH1X71heSOb2&TfEAy0n4Thn$ZxZ9S1D@Pszv*-a9T74rY6%`D zdn%fvAzX>MhE4f}o8aBCFpWcE-c}l|MSkT@JO@@(-J`^5U>ehQ7~)iKyG1D}PPOQP zaN^Dt+n9TPi2Z=k;FWnbUTG*X)8p1l&8wx?!;a?Z+xE+>`KLMesYDRE1Cdv*XZrO6 z%Bmzm=uO8d){zIX5 z_I>c^<3jCpTcMw>9BsQYx*Gm%ysIhSWZtx%X>V6`qCX6iR1AS}$(jF=Ic0vQWD|1+ zlF62gU5w%{G@3G88JA!(bU$b$W*Yz4AoS}sfPdMiAfr+FWYw+|a3I@W4S0=!d@fLJ z;k&)mDete0wAla1`z+oI_&qq%(#>8W9JPRaCzU0DAY#WXX9;C|`SUp|D<554Y9NxE zj)>4NV)1;+zQ)@18I!#3F29Q7xyNAF3E{k`X5Y7=-RTfQHxM%C8SK74q%3LAGJoUV zXx7pxe}1~+aa*$fe@TyoO6nOjqAr}6{mR=MaY7F}Xjqn)`?e8L?gOFzAh9jhW>H~; zVhXiv2YK@$XbbP5o-H%ZQ_BO>wALC;TI@tBBX%DxufZ=l|X7=ZGn^1!L>>Wt>V z(#dQ$4dF!RXnryLVjKQJ$Jl6eRXcW*U&i)rcjK;*T_(a;Qwnq=)DId>;VZ&1PLKPIuc^@ z)=4%a6eJX~eFKe{Av|tKaFCpLlAj7P9t4VC^82DhS(U>m*P=qXp(!3#0<6yoWspN-|e<%6T z1d>tLgLwpkIcVSk$qXg)t_C+gQTL;Kurb-`S<1gEc_9s*&y+T}H;&+9Cq8_J^d7#e zrIS`uL#LGyZ5-zGyg>TPd0OWnBW07E9tZEM`E5*e>Ij({YFBV=w;x=V+dgP#ZpUF& z9`Jb+JJRl5mU}bc?K_ay&^f&&_vGjK4ZR;1H2mlNsSU^1OlvsgxUS*L7p6CSyJikY z$Io?s`9g`a^O>8R?eE|0Jig-==hxeBbspJxoAY6ivpoN)Z)|WaKWzfErptFzq`X=sa;XIG@Bs$ak8%~*^uO|W~n7mSstBo8Cn79?J#KM-ZaeF^#cFQ^tYf<5QW{FlAK+E8Sj|#PVPzY{1liWv)#vR zw->1IWx0pe+|eKyaQyu{8~*e0T@AgTmo{`If!pzUnjRzv zWM(qz50>GYW6^pc%zcB1IAX>{B9Pe^ED&rEtPt!FED>xug?M|=TG;TF8a=(?aME=R z-38McdS*;*I5D@N;nckRhO*xx&>aiaJmGW-5Vw1Gft$n-K=V9VVQ6!C+A-uF&lNoT zY+CnWp2zZh29?0e^F*Fc5uMz_^CX^oY}(ONJWt`-izDO=&jn$cD(2YZK3=AiaS)r4 ziT@e=P-r{D`Jc(}3#>6#{^#=hoNYLR_`ii;GSd(pri~n+G;|)^l{+fd&~WK+W5box zObu7x5YjO2rqG57cZM}&=Tzld=9}#z1ssYG(~<`(-G`IHv~QEcRIMj1Ogl9?Ogl3# zOzSHR)6Omk)4T`5kjBEa)M}++B=LeLzX;WY@|Qzo75JKxGTCk(WzhCTakB!I3Xkgm zO2X4b-wo%;uLDDr?sTB|8RCYS^dLXn|D<_!1k)yE9V0U=_OtsDz?s7ue!>8InMdJ& zH*dnoMPugFbbGULsAKv=)}NUhSX_$1TjkjKkTuB=@A%h4)(kb?RgJ836$?{LJIKy< zF`h|qi$ywXv1zG;rx9^$@Qw^Olt@~{b+Yd^IWuFxG|hs%S+BFw$`+V6LK;c(%`I?(|O6I*v_4 zau|xuqHMnh8Oeii-PJQqv)RH?X$+bh_U1k_P4mFwJcp-=Em?D$t^=n?N3JSN3FlT> zmNiR04~vgc< zwx++%R?GmtJU$35{Q+DxjAki@8jV5pu(D2Y;&&2rU(EmnXEJtrBs>p~oW$KTf~wdZ#xFCSA*O#tj5VQAdqD z&vL3=S-dQjm)S&Du^ap{taT5P2468(o9t%7^SUZ|2ZZSva|q8e!tJH}TxVA_>GSv| z^+spcT%KphbBVL7faggV1dc)YdUoZCeAIbrSFR945swTz6}xc=#`Nh=&B*PT@lUe>$9C*!qm)H= z|4Au13k};zl+VezCC;y~nYs>d@drnu0Jy|wjelAqtWarm?;Gl|9yp6-L-+3K0hB^} z#9?+=iEUeDjII4EKb?l`8fj-2v3590={|^r@gDyh`2TwxK{N;uFOF}U^_%N#O-=mj zi112Us4mIA-I`QuGbMk9vz#55BV4gS;3&^>Y^J!4x_IUs7Clt`Yo5O(JyJ*L1UfUq zC9!E<(mQz;+1$gkoZljz9|<{Y=lOtfm^T5$PDsnJi=qC|8k_c>66#%`A|oA826+#y z=FF{>si&pa^8B6>(sn=@=sh%6(hE+rq)X)aJ;g-&0PmqJNzXsMz(hLF?av zlAd>34j+=|_c&tj0EZ16N|JQj>4WH9^8B8nkRIt(55-Hy?9*vv98SvT` z9#W#(+EZqoZb>OVz3c{dF27u;?KqIlhPR=8C*d`{jzaBItXr+@r%3)2j&{r~GF=kY zIjaiUYvvO6%Zqg^#%05Ik_pZV8F~-zxqtP!$=pSiu{1=+yYRv?{NW&h8)g9mZ8W);uIr4oeX*;1qqU5i^bRr#g zGQi)8Q>_;_^Tlbv=a+qOi^RUZD}r zO9F+AbJ}J}nW8`$Hx-F|s#oS!w4^|}e9Ap3&;1)4Jm^U1 zargCYS00mRI(Le^VdNcEr};BGXSDrPIw3Y(IT+;d`H!MPN%ulc*BCj&BaWSurdR5u zk_YngoeQgN+P&j%bf`~ShpYSE>)YN9t35u^pdG{Zbp_blokbuFOR9Z<`Nm_oc)PFQ zxBD)qZwh7R8noln`FRm)3;3VGuLsxQE&R`u0?w`|LNyY&bJ0hW&=jLjr`uC z6Ey>J#Add8jL-1~qpq5#H16Qv{jp7x`LAbv>lXetn|3$p65qd$B{rYc(T)@R0n$7` zC;bV>deu5a6@5+|GP0!q*y-CEinT$OasO{h`9}Ct`IDus@R9EFZ!N7(pKm;Mmh8yk zZfEk_litbgh<_(n4@H>rRBq4GLak%cQ@PFu*kF5iVNNo)#4Fec=0S(j>1Vmm(z?gz zQ^&6hR8n7K+^3`}_Ma{wQ$B!Ec92&-Bu_*3Y_xRPw9Gt0N9de5>lY5p(DayIwhDI;=gFH+nIB)5}Dd?Lf{{G+dA3V|EP z=YcW{Byxa#^p)sA57J4VJJqG=FEuDbynI<})4p=(?l6gC(YdNWDlFOc@a_4|pDeR! z_sqG`$#J-ipFdvO+6OKA+v7u9*^NIQX7#o(t5a|FZO;KhN5RLVb8T7*LD*k0`mZLz zwXXnu3OF4{@XZVr+D-iq`2>lfZeGDZ2 z>K;CRz@W8*>_Z>W9Y~cpNo`JI}Ny zl~uOKl*UMqr~>4h#G$@t4w|)IwGidWVlN*(6{d8#-Pab1x`{UUeyH|)2`NDrIYR!~ zw4$69c3acRowg<~({vh5UZ`v(^XzFG+`RBAb5lFqwW@mO%s7WHcg5Qb>?!myf!t9FpzWvd6a^Haay3`HEeH}|#HdJy)hmuo; zZGRz^wWIyx5y2%>q^+YB;Rt3&z(I`a$I!%jsSBhSH|)0WQx zsz3${xel2^AX5Zn1S&fslS2kV z!HF=0lNV{Q3uY#5YQ8*NKCx<3JrJ;5r8J>AmAv|Ic0h$MX^`I6*WA?PB=46WaYpM^ zwx(5|u?vKTY(QwO>C?#_bf6H()SGTvEDfJA8MPB>26Ph{xh1Cvh~)vX(_p_1h!s|c zEIt_@y5#uB5Wo$k9B%+}o&sw%AK2OhhMpn7J`3Mnj^7vf6P{aEiZ2keI%%ijH645` zxUrY{3{tf`Kq>R?DadbL_!G0;+D@w?4Aj}1X%8)0lNGAPX-G3RT}m=;z2-B(hH z(sYPrE|S~3suEgzh;{#t5>k5zIGwoe&c zdkA9ki4s-&4VB)3(li-*Fb}0lJjYNr&QKo;}%VuL#Z-ZZi`wFjD_ME zh(rnMvO`6e#Uv%V>_f|lkAm5XPAkT^$$p)d>l#sNlTl(tv0cusul<$U6Ud1$oC#uT zN;mkG+A1V#x7Vh6Sw*@Rq=&gY9Yw~5k(^+H;**PaPdo@b&Jd7YceDkYlJX50PPXjc%lF?(+ zl9-cuu-jqGNf7QUsYpJcu-?Et_V;OkyBBcx0P2$gP@e(RX94wK>RSgj41RMN@3OP2 zlMZ$q#vsD`4&KMoSgv83QVX(RI33bX!|l;|n!fIt z`wluGm~Lk76~mOzd=M0z&h~ey=bNSP)yqLp(wMl>%qi#(u;(h4tk^Fw^TfM4>5WXm z-#Fr_7s>V-8hQnL&`zw#B7C%9S-zgiaC3Hlw#)Av6p6Ss!XJHd3wd+<_!btr2EUAX zCWETfx?2&G)bE5A_ z+JX(k0|$qhUBgJzz1Z;ZZ}D88{$iTehnWcb(q^$z_h1s*B9aseiYP4Z_%(@wKjv^w z4j_zp3QQzzD#yQ0rSsuzvrM7`B5h3JKk2mp46U>^!3N{>lgfVE2(ke#kyzYbOHR5C zOB*=Q!&G<*EIIC;kR`#9{rVv(yEun!BD0hKWm;6v-~joFhhNRk< zJXP8JIx9fjVd7U^O(!0Q%0vT=(%fX>FT4Ke}`^WPOeT~n&x zoouLnZ?brw#qoQ3^mOR^B-|aiKL(-wL{HZ|X>2`9<~fOHM&?PIjKUEv ztjAL+qb-;JGd3;y@MHUBN_!1$KTSe>Q_D?$H{~+Gbnux{r+6nhuSl~qD(16s%G98T zl5iFjl0_hrEkzlJcd~n_2^kP0X*9~FR9f)D@F+RUZxYirgYPqex5yRR_*KhphR9P%7bnDJR!qzmf2&kZrET(EJ!>~fdPtd|yIUdhzL$Bm z20KQc+nA(oIz^(JkJr-h@_=Q+!+Mk6i&o-hxp=(UIT4 znh^wN6$d>2o9)jsmqdY}BnSe;yY~N>gL^@Y4p^Rhbe=Yh=LNXBi=gdrt$i`P3r)!8 z|0|*8fvK3!RCF+4I#J>Y6EJ)+&GGSb*4V5RTK2;^iHmRkvIkCG|b-z zra1;ZZ@u(t@-5g@E;fhKTK_r3RwMN|N}somQ2EYmh}P$d$oY~l0HdNrV>`mKkr&C73= z=ZE-CMy(1%5HN0~kg&+|cpq|>5c>qz+oWv=_1%0_&Ijv`6XwFla>%H72g?im?#eQF z{DUT&HWoTvP6Uehl;m8QyGItCUM`@86^nMuQ5CiB1BxEmZ?0IHy*x$<8%NZD{fwjf z1>B7NU5sMBU4R?l(Qyo7(;ZGR>8=f5+OQ50+cp$r1n%j{ zI?)r~7Q{)(!=x-euYixNPyAfs-`Z5lX6{hJb;40paZw#aA3PJPf_LHC)j(X7%rE%~ zolg~C1dWRy^C>#DlY`Te0Y(`lI_M_v?;;mZVbD!3p7C=Y@qmXs=ot@s!_RxjRg{H* zYdq)@54yIf#Pt*_N;rvMdJxNTm9bgpvRmJ z^mmnui(E7!-A&#@Wwm+mjGP8>f%kWtizi&Pr2ejRzbie_xfTUP@UBsC(3Iz#33P=A z0pS26v}rNqt$`7$HWPu^C>`rjD#Ruz)_P3$y5)Ykj?@I%V;4O67!p-!o)g0KcKdX! z9W&_U%y)E;tvF>namd~T-abRcRhnM}Sa6$P1TbQav;R()QDQR4SK&4Dz*}N-RaT;3 z$Pk^Z{eX?tCWI=T5eZD~lJy9G zal=o7pvw3eVA_5EIq?e?f%uheKRX@5xF4k4PZ%rnCj=4l-`F}@*nhmv{&&jr=ZrW7 z-Er>i++-7vR2Ji!#B?k-#@C*LhKM0t{N*rBRnrAbv4{hOc#)L8@LyuzPH#JSu4JS> zFtz0$z7m-lchZf`smy&I?=HRJB)#DcfreX#L0muQTX0DN&3gF_qw!PxcaSc!hD_hv zP;&Pdsvj8)Y%>s+-s@xTPGL%8&crdg1{a}+IZT!LV~)*v;dW&{$I0Yk4WVNzKq?RN zpe(m-{`bxD-Wi89KG<^30Dfk!SZfgzWgt*)L1=-fTcz@w5hANZUNEI{^k{Ug1e6$r zgzKwe66fhe*JdE;riLo^uiK!4IJH)>G?$0P)C!aa)3B`XQ~MClWaNN+1ZlOfi%f7Q zgP%y=K0xnbYJ`M~Y$JSAl}3WtxD!Su5)%rIF8ny@=`)P16~V`E?~B(%s=A2(bc@>< zh6!n`L#So*`Zev6uDY^N06&zPS zmQncWK#Z#%i|Cz<#y z$~~G%uGdHx?McLkBjo9HBxQJ1#+&FG2w48I$*wxe^-4LFg4a^8f9A`9N_xqAo;-ns znA3y>8(^;sf!Ow4zQ(6z{nH_tZ!xp|obROLLg5PgbxiV}4Ad!k(z!nc@;P0O`|HS* z(MYDOf|n!{Y!{L$BPg&AiW2H3Yx((Dq7;GHRe)HQqrU?&`sja5Ju<4`bV>*PVc+qt zZR@$}mh%4hB-ihFPf=3TPkWH8+L@OQ=A@pHiK6Kjcof$;R&u<@l6<_XIxJL;g&D5+o)^KT2hv3S=;9$S#CS$O}T}1bU{}AI7t` zFSI`-QEn>`=x=|RuspCQ;!-kG1C@Wxb8z(4EHxSZT4T6<;+=4}FA!Kvjz7Q3eGEH8 z?R6x*Lh;-9bQS+U1~?+YEequWJD%qUfF)@1c(}6aX3E_WNb4WNerz#8ki@VL0tPn( zy5SEuk8zD4Z9Hk>=MbPv{EomtP2u74z>7rRNuo6V9?bAE-tz+uiM>Pet_=KN8u%|A z#7P!Vev!e=1jn~ObC+{$Ag>Mp3u6_Typ_(0Y}8*K+PqjPS#=L1{*uff;H2@5G%oI_ zbY`GE?KY*@t;JFMmNZgZ=s5OC*wAaQqvBvB&^l%y5G{L^ zacKTyMZEu03j2tfWmjA~kwCJMWg}^FT$0k@cz(4t(U9VBuC``fmAx14!Xyg-Vih(v znw4f4a^?im1A8!#AfjFJzLh@LJJ!vMOd29vF=XC9y+bB6)^*ul$An*5C)})4L~IDF z*6Hi_(}(4gfM={e;2GOV5B+nY*rkr`dL7ZOTYoRsH2p3O{|%ES#Xf}%`t2siUw&oH z4DI1C`xJ-a*Vg!(kMrp7T{OLmamhQ23Y?F1b@MDDMgOfRVxSY&b+L54%a}uNU+1{| zWA;%@;J9_^UOh)We@?80+`*85Y&D^bllTU)GlkQ(ZeymTtvo2l9G+7+zfRm99F~@O zlnu?NY79<5t@Tk}%%W%ciRwKM&8lr-`MGmTRE)#*gmT@>At=wL?ydWkVVLS_^Ka+& zA6kOJpc?y&P=__90p2#FnL{qpUC)hut`4J8cO(Jc74X(#Lqcs%{Huu)MZO|aaVkWz zSlB?nP!75YZOIuUhuv7&0DZOMv>N&*KKz93<&&lejsc5wxv|-n(vA(|@hC-Qi?3Bq z&oO(;=-Ca3q7M&MhI~N7E7_x##i1BmhYlUP@^Zz=$+r{zl~~VJl7dhWO=qox6+;1&aNjlbA*<$ev9$ zr`eoXD^ka4c&LCkHdI-X!Jfwm1OJ>ma0XC&!uiRD*D@~7(S8i0aH1gF#=2Yg+x540ab8%YvNG^Z?L>3BH zP^gu-4q*g)8I_x_|IBF}+2Gg*%+9h3FzTZh_q9k27DsDfv zLs!jN$lkzUH!9McJ4( zyV;Sr)_QgR!g|fC@#6BW%de3E`5pOX98xC~R0!Oil6Red2VEJ*v<1JpMbhV> zKS7FQzjztPu*qI=`L4%m)8yKz8%JS*@;^IHosAsqxN@B}BqrIAvTz-}5#!2yRI~C< z?s2HvWF2aS+odtwa&9xw6VzRy_Ge`L%M5lr4?0{#FqAfeaLAyI;Ip3|0Rzf!xuATq z4%Zl0dy^;y!m^@WcbNoE92pm=GcHi(bIN3Il9RnBM#eaXt+&oHXpZ~VTjPiJqy^5f z92k4>TeFOvX^zVqtZ|O^^<1F$SI0N&t&@zmZFO9;!FpHXn|!M8pAP%( zr}b%$-)*p78}>``Pjbtrd>{E57kjKs17!$ze`NzV84cL#SooTCgdyFr;x#H=kmh*r zHSTV_JA-tuxZTKmnM6VKan3a4qCZsU6ZLTJ9YIvbJfO_hS-noTqHLAA;J;gg zb<9VbDi#NoG&U*F7>DgNmeJT@J`5HtqcLfK!&sPNEOgtIRdFuiT7L%J4bE@iSZ4ot zvKY@bf$;?U^V5F)xi3(~Z>Yj?<-e^lck zWbeC}gJWCFc24rt`Op~_?akn~nfPQWvy)(HKkgN9HB~-$$H-cIr~Z#4eesSf|7DG_ zNlQ{wi;!EEdebhm{TXUIfj}c-La-*OMQU@=3mI}H{r$j6pHsBR1jlO))~NDWf@l*! zOwsqn&6-kUu#aYV2aGY^ts}$jG_!9lApZM}39BVGE&UG=(GcdDQzKLjcNwf?+ zPGo_U-J$k>-{!x@(@dQ*nCie_98qT?V?v&gA@Gz?sBajjtq|J!&QNHR^8n5dB~XtH zMd~WohZ5(?U??kPD9^rT%{JWZc=t7HTF4Djz3pGt1jip=x0=0v+T$NNZgX1G%u8?e--qb<=Udisnd5I2{uILHj()ym!sf42 z2htRypAUu6_C58EPN%ilaE0SCmvwZ+C3?4_U5B5~3tsq+6kp)>v-7tb{t%n{jE{ea&3!C+7h!XI zB>jikoJHqzEp)KP)(=lTs5lt?@e|%-nOQi z<;pk;*c~lzTVsX=-yV?r>yYtUQ7PRf1rH{?_xRh^abX;D8DkD{eEV_OwMO@5$HL9l zOyg^}I$qh#Rdx1T9DkAbD^ncbZsu0I3;{sd>(=bZ2Aqt+x;cUsUiSR&tV4_gQXK1l z2fqqQalH3C>)i&gBV&toqVZqJj=Q%|-$%)g-)yla8sATL{CLtM*BwjF9g~qd*xdF&vctI5dhLi@W}&|3 zu0G_@#%OMZ5OsF#0=X%XpxRpdn3*0>>Sx9$0{H$YOlQl8_x71%2hNC?qPkUvzlrf*flQ#j5j&6Ln zi|<5emG|Jk)N}ldUeCxE`qiTb>UmWs$1$!M0O5_-9nb&TIx=;Jj`27VIQIOdHe?(T-vX}GPX!dL$`5+jWo6x;v}OhnzS%_GGT0Pdy&39qe6YrvqRQlm zU9H(@ZtJDQ5~b=de-CN-&vBlG%t?zA;a?&K{2Djax37Jdeo5~;?${Hx7FGOiHusKr zKw0(jX`kab3);8a$c>m`FiWlsDmBAAEeMJc&Trt8MeoQS?GTk1Z4=>WMqCkciKF5o zsGxD(UKAM`#$@EG|0P2@{G_u9KQ6=CEV2~QWSct}o*AWdNv9~9iuN{OlJM3&iG*F> zLv#xIei6#*BGNf^pz+P&GKd7-9kP+-q!Krk^ifF$ zP8@ewzJ5xkEQhXqZho~oMf+Oz>bb1B%iHWuD-c(zT=+A7!$s}UJ#UXh^t{3e|oyt671*Rt=K0q zZGEK$&dxN<JzwSZoO#pNDTP8N1FVT`Z5sfSaHj z?{k^2Ru;Vu-r2Es0d1BJTB{D)JrDyYH&B|WYtKmTDKRBy2Rq(QXI|5n4ek=wX9XR0 zn+lvA(y`E=aElF%Nw3PDR){9{_iP>dkv>0+*1||m?9X#{VoD)ScC%2Zhb|aN9&Afk zve?px&{+wa_YW$@70I9+wp${(DqBZEPR>Br4jN^8XBM+AidY|otdr@;l-D^~H+IpH z_qc3MXVw(wUe@BCA+{w9Yw`Q?9ARtbx#=CA?`^O-`(xG5&!x=*(+c-LEboJDOPE_# z$AFcbTKjk)Q82^9#6L$GIz#-0DEhNNT7PWQE3|O+9^Xk_&pi<}SB`SJH;^WEtt3s3 zdh(N+1lKM=m>ll7`485s)BiZ^{Ec+U4wTl8xxXzslL0A}8`F>vrJ{sLCD5M2MZ@K5k@u zQL(nk6_?U;In#3x?xIcXA{=NO*w(Yd7Y49$w}uwd6bfqJWN@VyjE%Az8*Pr4y}Bq(=ltJ z6$>X#dbvokm{Ul^dM5h{iy}B(WH7rJpev;Yt5*&Lu^E(fUc zvCd2i4EASc;IHj@(s_!DsB#KmGBoZ^hJ%sUv4M*dVIW3!WWotK(?M1Zep+I`@pS8# zCf)!elS>ov36=yeO)R7SmPi-d}*M(bCVo#r!4wx$jZ8e=oNLJfO3Ug!zoaaxxo9S{hG9hSST-6ek)P8 zH>g~QIFkzz3wIj!MFN^PF$=v3&pFP8h~}LJ{rr}Xkgri2id{;OivbK_GiwIm1)?V}OWDk;%AVNxY)#P-8qRD#e1J6Z5`f*JS%rSfiKB&=hm4zvUeU3)glyMyP%goA&EH;FuDcob6ihrm4 zF<2cg!?pzOLIp^BV%YkD_%I*V27Pc@QB-GKVqIO?LAoKtTZT!n_>z@%#6C7kUJ-d| zsp0z;Dz@g8MrC5AA$(sk?C&Wh;s!5TW=ZBZq5HD(!uDm%50i|XN-}Y(YtOQrOF3I& zC4j0lSlU(^R@NbR@?AShC`SXB10H4XQs^iGl-Uq z&cm9{T}h6j7HgJcaf@}7VW;EQJeO|6=p>_vq^BZMbAxM}jIIbf`F-EyG4m0%A72u= z`WU10F*-9_=(|hA5Ey+zgYLc5m zVwA>T_CC2EB9nO{AkK*IBj6ixEj4rH5-2B;96nYuKm7<8_8>Xspt^);rF&^K3y^St z1t!}Pxu#tV(L`iqKFiEI)1Gv?km#nc08|M}xyhsN?7u8qZQt3Kzpt*9hf-tuCO~%OMcr zb~44_s|t{Fv03y$=zy3mB4wo8oTXsmo@AxuGK(!IYrJi7F-|&hJHCNDU70t)l>sE~ zM-*aRXVyxmcW?lCNm?fz#An3@%KhI#^%gQRvO1}WVP@Z0j~(@(&05QzKk)7a|}@O5+Zg}U^i6e)*jU+w{`n)6Sd#G$M(V8#B`J<{sQ; z65tX<;tFmn`M;1jeH8Tv5cv(-=;eETC4mcfP>@+T*@e?w?#S7T59q>V9Z!MJ9;mm#CkPvX zQwE}qz-OEQ5-e#$N#)#!eVmk$-5*N?j@L83cLL)xOFk2f5ZU+7(t*H&a^mfl^(1RaP*fU!md1nY zYD1v2Gl0|gJIj&pGO95liY6=fmlT!;k|!%Uvig%hQ5Gk^s5H$cT~Kc)vP^KsGhl}Q z*Ng{eTLet`KDGb%$T|_~|48==sX%WSRH=ZWS{ZSMP>%qG5Y!S1aW8|AC?$OmXanXb zzebQsnw!gSgp%w5I$%VOXbiUnRfb9Z8-T<9Py^S~z|24cmj8-gdp@ux=Uj+h!7;3W zDg00qAJD{=j?@pW$%BRX$*``6-dsYf6^_{?e-CM8WI;&C2;~!*PGOzmjQ@ZUi;5R4 zBl$?R9L-^M38cgT8C+!P5N^po!uOo)59u42DfgqwU}s>XSeQ3J<@S^aSL8(&RRwIQ z%QVI%oTU&fm(B#0wEhZOgsz}fy<`UWE(FoV26Kb~96%|3cfazYA ztvE+QDr6)B{>zj+3n?)p^S%kraA#5NTL8D1dLZ)ldPY2kMudS2%i;8N;kpjCYb`Tdm(1kWaH9)x(kl$6q&ikFrYFLOVbr$-1F6-5=O*j zF)tz+y|Y9v`U=r6`dTLl4iV^oR(j6bYk8pko->EcC{oM;k`_`bSF(T4=Zln<47jaGs$$D@>33gFQ#EmGQusVU5 zura+7rdimbh;|krD>O|{hc-*aBH1014lroT_5BCU9TCv}2xMNXoP;&p@$dJo1Jz90 zDx$uK?Ef7QW_CIDpP|d4%6KOwPsn2{2Sm*yY~Kltww|!6MTv{2i3A-k^+= zXUtH#+zdoF=b^VQpwgmY3Eaq*vS=rZup$Z}-lEK&@-M@pLf)U{dcS91O|U=5I(fdL zBD=vdkjq7rKdZ<|9)dQvXf4`;0LPOrz=3p}M~M-ax}Zqb)IBfHq7hx*O;gzSWpceJ zI|b7GGH?4d$H+EozFLfmxZn~FT4x5(TVobx{qGPgM;qx6!2!h=hu~EDk$I&b3j`Sb zFhO;x1{MqGB64x)IjBS}+R5g_??8oj9V$x_{ZLs_u{a179gVn$T&9Ns=_n|57Q_D@ zWPL2;;G%(`pfo;L&`u;ZBv0!ls%^0#bPzA)Bf-CgK=2+uEDBL(&rmFZo4zwDGgv%D zO3UpTT%I?DRetYec2!PS#>sQuwM>arTqqUW78g-(E_Fy+2D;is7m-S1nMhHxSY}Yz zHQFyX3myrWtdN)i%zuB<;=Wobc}u_R@PoRq4Fg>tPSWo(@>3NUn}~dNMwvPM|DHBY7XBhU0u z3UPwm7t+jo1LEIeeSw%TQW1nLryc-=b{5Ng7D_wyMfzz`DS{3Mb7eH&wTU6R_$-1%_c_!aKCEufVFXIly3WO-jS`2@) z_F5CvH9yBHPim!N)Amw22=59I;%5Rg5Xi~Sq6=BZN@^Vd=QeZqq!nv`eD@Q|clFbg zr6>Q5@iOl>e}^g^9d&U`&Istq9>flj0Y&igm?GLlC%BmKX>r`J7dvfn)OY6c96R%a zmbhicFiyq@Zh9~d$}|)~lcd`Ia9GHS&lCs-W*`rROlF`6{pkD*T*&Thjsx#o2dlD6 zVL1&A#@te@_d7XLuGcuqvrL7kCzgN?b1P}AKz)p&<4DK$1azT`b*z|<83F@Xf$QKR z14!F1Gg(XrWEJK=&XvZjnsp2Mv6o24vd+kHj+@)817}H#D=7F8|OvEJJF|fK~obtzC5Qk9?skKUa)HywIbQ1xzCN{hTbs zdTyP`aEa`L)3i1?^%mrC^BZ9EiyvBJR6&a1%t;9uyU=R~HC!xDFaY^B#*6DvuwPUt zIcr^nZb_5i#J!Y}>turUN}V<22Zt|qFbip2rm8G`?2nVFGk(&Cvisvp(l;U=neWW zSBj82F?fiv!-BEn7V6Z+GvOpHn=zpa+6NT&{!+n8$P8sg;&W$xFE)5a*ax=P40(a)WA`Q7ucTw5xebnM zo%IkJ-7nDCcD#v68(iClAUbj3!`Yuj#Mh3eTno1||AK$9DgDA2Va40N=x$^Q&TUdN zn@QY)5AAg&+M9;q_9+e}7!{*9UmmE;R+k~~p=AAn3b$1}n7uV3u68Yzy`Jd5`j5jS zGh>aRZEUi$JN{Feec;=GhZmn-E5@$AMB7UVX`j_avw9viWlvxun4SB@?4ONL2JawD zc>cGlucUQ&c{V4n64eO%$lea{Y=RlM|HN zM*bGc5rygOK?QvX6?A~)6MA)oM&-Mwct1&p9U-=yhfqgl*Y}roTnn$b`YuT6KJRdl||)4m6DgQiOziJTH8xfUJSQi;zs%f z>ghdy{^?8Gn1aIy5C@nBH@3wSCw)<;Djor9@jBF|#rZqEolb9)PTxVjuY~kX{FGk% zI}qGddO-(VX0Ijl_f8ogZpP&S7QjT12LfJRlLpgckT4g$!q zmX*9b%C`7*MkgGSZyR5ZvFT8hLFAG4GzXQOFpVQ5DB1OC2=`IHZsa&DKq;WQh639; z?e>>|>azf-wo_SL5}=~Dw;KJBl1m}JBCcd7ot26f2L1)vWS`rXz;y2J_LXb_;bdx; z!7HWgk0?tVN8>Ma2y-7{5X3tGu^T45|C-p$`ap#@QK6U=tB*N0}xR7v{7 zl(7WMKuaXVQPK&1!j~F+wq`csY<|!f!-*&%t_3#5F1}w?CB`8SYPKJ#B^_r?h9TNJ z5;`Dj*Nm)B#dwwSS+98*S$6AZGKm1fTE+;3;W0&N3ZdMEWn@dW@Ev7k!wrIt zOJV*+dJ&5D6sMAnLducKkI+N;hAwJ*lq||Y-&glaf zhWQuzpOZWb$`p0+QHa zUK5GYvv9eOZBb|3^Y#gi+72_(j!N4#LtxNt_O*hYTAD-XZrdhp?Z;@Oi=_0h8ViP9K9w{ zHug&p!I&_NZAN3a6Vhs+VUZmq&i>30pTif<*<54k>05sQyvutK0C-~?3;Y0xcgacuDi<9%v7)PzP5yy*=y&k>2foEWaDC9@ zgM(hjprx=3S|HW`gY|lEqFoCJ=9cTY&N@;b8yK$wynW7H%g$kd6AZWz2;akkH;vuv zbsm>2vuQ7gt(qf|XO{4UT27Jwoy*n!pYepKNVK7JLx2?Q;zM+i{bh}UA)ErGx|Uu5 zr3ZOmOQgP_iGRPYA4>#CayWMkvtAxzs*RfZ(+;`G^(%84-ZBUI{~vcg^Q*L?z$Tp^WYCI(G#I57Zvl+uZgl>#Xy<>qE?oG` zmKjjtR|YmoUhQXu_8tC%eZ`>Xla*vW;ca`e{{6S@w`UGKMV4f7b;z2P=o>Vh{Es;} zp|{-T7?!*C_v~X#qZ9S3{$w9- z8ycCq{ZHv1y=R}q>8z~x?MbG#TlA^#+pjepPS78C-#*E9AR#=%El=q$zi-bUcJXvS*g_^lr}V81wdGY^jNK%6^r+x$_Hz&IQj)GzI} zw`vvw)U046(flRA$_0+Kz!kLRGO)ng&9iq_LjH>rczb4s2`}gBAr^1JEb{ zGxH*KCm2ZBjJo-ZMh%Ujb9?Jlmk+PBFU$B=eG80llTS6i%|3}mw!PHVZGXv>J*Zh& z-Y+5jB21c$L!Y72pBoWWSmdg!8;`mycj=M{)658T2ky zb?cWMd#E zt2#+AOf0&2qw^R*)k@_U1LStqe&8&vtq8cNQ%d!~jX zyvKu3A9Xe2+xjs;xHp5PkB4`ehPdOgDSS+r3)(Xw+*TSo;6$+rDz&vvF=iwzq$dmH zc)z2cpr3imV(jjTbfA+PgtPc4O6T-!I$&n0W`fK_I*w!We_wy?WBcDsA3mW!bIhKuZ6#h> zDC0*GOIE6MS(8-`=M(iAOGsas0ov@G-pS2GTPCWW*>wunoXC3{u|(E>3833T zfUK8Qr?G|l%nol4&AHUh>07yPA|m}s=F`8>pveD1 zJmsA|Cf$3Q_X!Ll>CQ6p#SmX)SB~uh=wm#@K6q?1ei(`T%H!R*nDB^48_%T?dr;di zCzSd)Az_XAI=<^!?!5bL%@+Bn*4J%`o$m?EL!{;KBX{>o?q;d!nW%@}Bsr08 z(cJzCgFJQ`HIdL#a;Eh@r)=nppWzV<1>;i_T*0&$BmitO67w^KLq~1n)GkZ)y#*Pn zt)>;jrQd9792qL!FWIXtEAI`AAj;ROrsWfI{OEt0N4htf%>9{9xK3w66#GD=mmFNT zq*&d#90;4*Zc?W9UCHH~s39M+HhxU6q_I7$sp~+j%Ix~N46O{Eg-j2Tk1(2c`n&B&#m(tjY`tnKLtYDUg5LRJc8Rx znwv!T&mnyFG(gh0Li{9vG&4u#>h&fOz2q?1Mn+1W?IHDs*av%aV2o--U>4!RvWDbP zL3zE>)jw@xy0DGwn6~*T@URqzv70N%FQN@f{MzZvm>pY-*&V0oiCp+1F|_hM6Hz}O zk|x}>7a)BAnKiktDGLTTKh@^0gd`M!=+#L#o$el7w^ba54^vkTCLJHaHBMs*GiPcv zQ)++O%EEw5o2|OApcikzsll_{g``Su48S3(DbU*=npZ|;8!X>Wqk_GJ)yQHL&$cYS z41PEGvG3AA86SxNl=mFPIs5)^z~S9Zz>-5ZLhBst9C@}rrX+9xMp{C7u>~J7YeSG7 zFtb1_L^ilkh28A)aD}twWp^AFq+kk%$WAB-@4{STW$~^4YiL^^tN!pbVp&ow65Zi; zt9EzgI?b1dow#_l=5whVoeQqwPif35=faquI`cxI?PI|D>*VRKGO>W{*Lq~}$T>_| zXtH>si|q>5O{m_kYwyO5`oBTQCUKfa7V(7%8Tan~-yme0|DPdbJ~B=IKS{`lx&5~Z z*>sIc|5HM?h>`jVA*;JHFpKcC|8EExL5TUNJO6hH8HZ}@H2VJ&vVTO#o;!z-jna09 zMY6vja(y^f=`fECi)3=RM(hKINHznJ46*DhA{kPYh-7VpZ~n4KmJ=1pQoJW0XLZkj zlJRWyE~T-dL}?TQ8&{uu8nO{-kIFxs)M;3#bS0q}ybRZpTQWu7JAoYc2~vy)vCN7h z&^b4$nMf!@B+KT1|5#2W4HU^BnTAM4gMWpE4;0Bxp%4tmIw?YqAaoSD0?|zKN`oSs zMMbj^98N^DpG3IbF1ax5T+u9oL{v1}Bcd5B_(GzYz|6rVBX_a)rjmKOR0*C$G_xGj z?%)3e3Ad2V-2P_=dBkJ&2_jfnJR9b{>lxYSWgbEDD58{Le-h~_EhaTAqTwYu0f#HA z35#dD5!tv@!Vu4no<}@ej(8S|h-ZY#%-f5wuy5*(NeGe3)BvK{FA&TA23HJ3#Ijse z00~7f#4?6ghEBMo46jH=!X-sK6OMVBAr{fB(;!Y|o}&mjg7^wVw94C(nng_O0fr%> zMW#p#0~l!l2t)upcLqS3_esRG@0}y69Xb`7_gw%=Uc*lSDAepI`a~KErYLD5t`*mF z@ff2g?-~%jc0^Dec^G;_Wa!L9FG4kn*l6=p^>XeTL`W0#y^Fnha@T!3)usg{SC*s>) zn-Y8*4fpD#TY;yA=E-?tx&7=!hFryo49EQKFb(T3ubEJgwsJyXCQ%=6BflL*gc9-1 z!@Ip?qP~iHHV0IX#=k=KSfC=lV@I75DWu=pJL z?W{Ovh;J7te?jq08by4o?+J@5$*5{}S5vI$PuLOB_LA4()kK&1Ymd^?J&Dgr^wSFw!msG25IxbIMK|5@qlieyGe5X} zNjHVY*)=sR(%H~adnc%8bkn}E_zoe4$03GQ5W+odTZyKMs*ycxc`hYO?geNjlDFt3 zQ3DX5_2+>Pd^A1vD;dS*A^8bilAukqSsE5XYnK} z83*=KLEN@XcVi7U46%_&Z{%DZ=if0c*bEbhd`kmzZf~i2XF!Z^qHC10Lq)$8BsSrt z7pguGiF!zfz?ZrbLKa1C_epl54FZ_UdfbD@$IvHOkF!u*&#XoC3G|Y)`osWrA{TXH zAe%6>^$V~B2kTO_^&HXqMO$a;4r}m(QxGvjT~9UC^)oCXswzgc_U^69hQp$@GeyD@ zPQe<^Xze7d|FYKZsA(0m0IK0X(%QAC+Ac%g2S!n~y@4agRgi8&A%Sbkt_olC87BV& zwes<-Awet+cJd^tLEdFoMm78@=6^mz|Mk#Wja^`d;c;qTR%CRWMm4YW2*A%^0q>u~ z21*BL>_X;+ABn~;S>o;XDt_vywC1#pbhh#4NC!h7GeoMuep>_XEuW4$K01Y}JTaz+fv{ z6=s3l4?^?$Indn;rk6&xqa4E*7rt!CBW%ZhxU_6ZImF$`J`bz;cn26tOqH+dR{-SXSvKE7u?^*Wn(HFOwIpM4$vQY!1AjtDc2omTcsuTlDKQ>$ zjcKp+qOX$cLy++vNb6U~wLL7ieuZ2^WuxSJ>ITR)o1mL#ewgx?$TcL~s)>QMIBYsR zPA1ngrb7xViI@)A!e>l}@#mNh*|vt9N&|m|Iu0bm6LAX5mX^j1D$M(V;%U@!s7b7N z5mavB`AmnA#-sA7L5%DdmQMw!K_hS{_DXL_cp*1=4=^2KIW!Nn9BSU}TSPX!)^i#R z4I+ee6(vHwmh>Hwzp(lpr5JDHW9BO9?i%p)>5zI-N1mV0bod5!q_bx%huXQOLx7|m!Cwp@ zAv^<3hc%2;lo-tvITXHNi^FsnUlEl<&x)Zp<-096j&qABD)b~XW}{QAhO(`rY@mgm zqZuYcw&{6g&l~dG^8qCCr_d72g-$UW3Xn!B8?+%F+5ml!DH%u`#ALYltjX|<#W3%1 zXr4%kHbekKPVK8QCyP$l?w$*o45RNOCc~Qn=BERO$&ejW#;RW*^G`(1Gv&1}SGb_e zDUCwPb3P2qoF*CgzU+QZkxg(7(8h=07#H&;j@5GB_1Scv>V`?BSt`UshwUT?-FaLuTsrE4RiEX60e@vnkg~3SVDjy zNyht-v)sc{ai!S%SHWW)^m{3F@(^28b>~*`PcNCYx@m1niss%v=0{B#Fk6If-)?qz zg*v?7MI|Y$#O&OfpOWGhcU~V6Y;F*evh_$0l7VG&BuGnm?^rLX`iG^sv&<>|LV&Aq z>v0hv$lza08SYkMLR)2DxMJ$IZel-dM@%jd6FEu2I#o13bm8Yb; z=TB>*lUB)IXmm0JoJ#7b8U?F|rMsmP*IPD9GQKPdtu}gU?Uzg8PS9{kBo0q{dV~yu zQ_Si8nefgH)D8C(C6QkCfz-J@Eu!ph%1UPh@m*Z*G6B}E=N z{alX3v4iE>$?|)avg_IC_n6`}_fFQcxMInZ=!<7vhH%NQVjUz55gU!oyA0>OQcL2O z!J03V&6mlDWZ|$6;ieULawYHOiaZf{1-TxOSxN(|`8@=!QGCbZ-|pdCF?CLN89w(S zkNE1}tRveLe&I%`oGH$FDqe<@-o;0y_o@P7L_Q=!Qi_N$5Q$L1YSDufc?b!z*znEc zFdPJOgA`vk%={vJ@{4e+m(-FZ$~36|5Oqo@cMv3BWWip~!amHX_cKbrB{IFM+7#!W z!{QNgAqv_=zC&b}dW&xtR6)p;GRWoYhExd+D^F3}H7N=1E>zI&$s6DMIA`N~wXEtq zo@e6dev@|(1Jg%C-RvB!VB7c5$tj2-GG#*MJ_6PzM*gBB0Ix|Q(-_)-Y(Xg?F0x!I zRPg&CtJFxfZ$#<4%72|8!AYCD(u}((uV2f}5{=~$v_CMq|``ANy{w^^|Lbh% zN_r}6sPf-nL!@f@*V&LAHYA5${#7<4=DR2x3P)!N8`|ka0S!)AH9C-K+UUFhE7F7) zxjWgwiK|F+0h@_u$(~_FL_ds@}Mfp*b9kGb+ z;bX8Pz72N7_x~0}WI2Dp7V0appg&>@2!sfU<)-XEk;lH*(8b7L1%dTn8&!kMbi6 z!p_kS2M0-y!je9Q?n$)gM>l$#m!d?(&-FGx0;g{@#hKiX=-)hoU|tIcKZq&svPq9L z4MOCIVV=#veRE=CQzG2OMI7N$%4ldsq5+vFJ=}D3;v-EPl^BE5d`KIzMiNZk<^{BI zBO=Ti#oO$r9fzX2?TQ^W9R`Ciho}?mL|}UpiDQ{f2`*F7kWn;(qH^6eUJ2d>V2LlR zT=fHIB@HhF*g`{FTHj=!hHNr`5$elmjAb;aF#raQ_qN{ zEp|$*$LN;xIm4aw zju=PIm)O#XzlYh9Xhyxq)t^_IJEvPdFWp~#^N;>+Xh3DFj%*ffX&2JgC5+ip>R1+VIlKrl62iM=QbSYb zgFBsDCyi_tjj0GNXq{CF)-rGstSDMj3zezV<;@<6|F3iUIO^psP#Rs7nMGOApvK)a zvRUdLf-)S30<=YLu@T|Sg!8KHXC#S5gA!QP3z3f7z^o57DsSvrpyiTXf`$|T+W}0Y zjai7|a@M4X%B>u~Y@y!y*jfAlt!g3GX7PNQzd;dDMxjUz_oM{uwK^s9)#P zzLuN{>Egc6KX$*i-;G98i`KH%#8GK>=3>6x^hS2A?1eMEU@t`Xfv}y~F*diDrqpGoBv8Q*l>V^Tzm^MU6Xz8g~$SXv>Xjc0nH}S|GxAJL8&bS*f)Et$nc0nMnovcwe)OKq6Qw zVB7W?&O1isM-lx_5EFhXjopooxbIS5^D(AT=G01KjRKFAuSHb7GU^sZRJ~8;lyOFM z1}C;KjekqOZ^@9MUlUI1`on8s#qahor~O%jvf;OI)658`ZRPnuPP@cS%4TTGKyE9W z>EWls+_vjS|CrnMpn?jw&6#?Z+qRNCsXby^v|ta>2|c-wk0c(6?$}3;$&nYaoDcJ^ z<97r5B128<_n6Z5nJG6MU-JO1Me}|={laFl^=!U8W?~&{KNse304m-iB!y?QjQ?%@ zi$L^K$OXaG#u*p+y_+(rtY1Ou<&VQ$BZ>c|-sZ-#(^6NwatBn6RK^%lrP2~+Pfmvj z9b1SqN}LjADw#O-B6?DTYAMg)M96IkbxoBQFwlJ$0zA+_cL+Gbdi>-v#J>=fXb+ib zNgpmmH%Ghl!j`*IdUP=eMAaoR#EJcCpdl`Y-b-eNy8s-|8aH(Ahwuy5DP1819MQek zs&R{#Vr3MYm=72kY}_BIDeg^TZP?6EMa_d!c^4wJRF;vD3QMu*4Xhv=P*^ z%vaO#wkp0=j9}FCDD`?6!Km)%P7RkmZ4Q^+&FBeVtYh>Hi=xQ=!utP<;S(O$jS~8= zniT6v+Y{w!Jp{uHV~=!JjstBs+=rru_pHLTN6_v@oK*49$iTKI~ztgNgiXN z!p9&NcZK3{T%{ToJ24|_?B`6_#uCb{gBUO4BetyZSP?Hnfyn~iC2ZYG*|r;5gsrlh z=((^kk-BA#DQHu~(`(1ZqvuUPWY0wB6Lslhn5KkSC>Y!l0G6>zOpiN!oeY_rLCS=m z8SKKOb_q{w0JzG2-hnxC5&z#q8L79Q(U58CiFC-0De^ExNtp35zQ(Ge5S8x-`#7CI)rAW|b5&Tton1y(e4bR)5n)5bw^zEGcuo784~xc9)Ej zg^$8?{gHFszh$1s!`ozDv)Kuf**Kp6MUmLa0+YstXlmXy4vsuT09}SD z-oVNSR&PS3&QxaDqm_3BL-WQ1SmYg zvlEUv|7b`bp(JBpM89I)Ee~D2|NBFS%kn9%>JJqA(AMZnbXl_X0zTN!cmOgx{Ge2}*_cI9OLpC#`RK2K`E=enMin zK+DSQ&Lt?XzrA>UQ)W!(ba@xQQ|YQ*`3%n=ZW=!vqq6|Ag0QOc+~?eS@wz72k(qG_ zc*MpnMSuIz#cK_{t!3p_=Wf=cd#KGVOJp-AiwKIdde;NQ0%q&*BZJj+S=7~{_Xj-0iYnM`4w)H1VYTxD9x z0AE?6_{xlsMe&t6&-%(z`giC4b7q+xVU~t`LHVewjJ#x|k6+!idZ?A@z-7jC*q9DC zoz~WtDqV5Rhm3$i&xMh#fdSTJuWg!*a(95~iDNqCedWrRor|;AG`Xj&ZBm#HdADvi zayG-dID1`_gXxetR+x?_$rM@U&^Vl^^pH{dRC%ig85-nntoHFW2IIJH8zRk8n_NLh>Ibn**3AZ$#PjkQ!yiK;?s%8N*4cW2<|Rbg@{H-3!^)R z4%7mtZgIdV(PNcJ4+|EzYtCq)UC^~$35;MjNz~UztoAoJR4UFsb_$|Hs&SBY31w;* zA9gN9ba;lSuNKsytso@g!e*w^hpkf%wBC){^=2_QOAy1`O_^F4>`?+XH_|C_KFWb$ ze@NrX33^gRuVht^1Nd5g$B|J+fLK7}7~d006b~NgMgTVY*tSwf0pSUPO!ZM`f*L-Q zs?e)=rsjmDJZt+ z7uol5G}aGg?32-;Ts6HOLT~5r;V#lH(N>@5BU7b_bCBsj%JhrIYEa=DC%DUR9PfUH zsXH2RI95U$I+>=t#CZ2?Ut)s0cJWr{S{k|qZ{t>aC=9ZKEhi@lqX1i|P!2E)z$zI| zc`pnykMgoJghA@R``VC9gC%C(8fJ;Bx>F5j<7+=4JQKDTWr_7XAIK8j51q>rhk2jn z#IraXkKv@$Ge5Pz7Keq-$7e{@U4ghN{qLXJC+pjP6PKYoV;q-{`N2nW+u|T{E}8RB z5$%$O$#6KP!gN&XVN7F&q*WTJ|J33rw=9B$-$pB;E(*jc^9TZGMtU_o?-3K;j*CX^#1tewBHP-R(p#4) z>Xs7ym&uNzgu--^9E!6Gu`W}FsKq*Dr0vz^bS<{nvun9NKE*L6eK?y;ZgV`y$#oOE zI@9dx=r$|9?JfFUDUMDSZBDs^vJb*fmp)N!w7L=G}f%k!K@Pivm8-5nLT=0nER0~`ZCOxmPLie#cMOFU}ri73AE$!^~$dqQ3(g{-U(22Jml=(|C z^m)loltbeZq9B(sONVw-QPPRHa+Jos?~CrAeG*VFu>RAec;4Npe>BW-#h_9K^YNKTOeySx3{?rHaOGoef~zpsqD*TMks9nC z-<_Ty+Dmt=;@kU0MFVG3r|fya+Q6MNQ{??SJJS>VjBrNK_4h1d&dJ`D-_F&L886Hz z?6q?R9gW+#f6ACk%FlZuHK8!e)@&uKv;xIRuj7pCX-vm6x%Bm3dSM~M8rM4>>ozwu z9juq|6i#mJ3F&2vWWNmWFJ>6Ku?2L;sy+^5Pm4BQ2UXegKt;oWFRTrF1v#<5k_c73|Bx=ryG0(chbCvuV4zF$uE0Prdw%@kCb_wGNP?VWUaux2PMNgxkp4k&_`t9qQ*|P02$j zk+O?_`IP?fa7SA5bMoJsPhY)iA(j6s*dt(#2>s4QOy@`+6 zCrS1d->&KY9CH3wI6eMFN@I(;*_qZ_65Rf+t32B(iac$LrhD!z)7PgvCMA7>wjgEq z;&h6_-Jk9lKWLEUShgNB!cl7ay<0CI;m97HQ*UfDc9qF%a@EdlfU>qQQc9(z@x#IT znh}mdb*ldA2*BulO*Xz4RIu@#3`o*Ihr9+huUQH;5 z#B$-A5q{NtjsD$Hj%g$Q^y&?D10JuwhEXMFV92Bt>aJ1Tu>RpF$0dVqAgzt=dec5q zzihN4LA_Hi9_<*BeSh%m&cBb}3OAt09g?z#gsdI=iT=Q7$NcO&jdH05g}8;^wQT%w z_)03Vbc*-)KhaN*cKr16hvM0f`0uLW#y#fF=?!Bn&D{^Ve7!m7*bfr=!=AEcaZZqg zh6T$!WTIV z#tzFR4!2lHi)S^iMZHCF6@Kl%U7pYv=4IXN=*bZMs?)4dSi=I8p{kzPM#xF9I*$9v z<6ObZA;#6LV>|1(h%$Xp`(i7X{5crcS(iGRmrQYaHkvp}kbRwTL87pO(X8TO^Ds>) zwL|}l-y-N{OTuM<*q~D)xRmg}jp4A+m?Qzsg^o#xhxY}g>!s3B1)lm*f!u8yDDQ;Zse?VtET&;+m+cN%VxfTNhw*C}YIPtCC#x*KqGt z2x@gnY-27B=5igPj9OcL_zqa%7S8LiIGgWmmtD+gOmrdSdg>AFN7A+AZIa|YGyLb(fcR3VY5Db|AY*ejMNe2$w454m0;fv5YIaq zn)eKWxNoU8_s91;n(wK;C$Nz~PRS$Ehhss46NO$??usP@S$MLfVMy^l^b5(cmF6Ad zH|9K1NSi`)=$eavq&*x6pT&AjmYo=-u7sfSm0Zp8%!&^VPpv{~S*z(xucF*q7?Je~m zIAw~vOY5Ax!9a0_`{SSqy1#*kUP z^R+OgUS&M9^33MFS<@fLag3i@#67Tetdo~aWV8esaTp0wOW8fLZ{@e+NmxSU_qTZd zMzj6_b=7C}xQiS&4Zem)-e&zjE^^E*fAyX4=4&RR&BUgX{uYc^lB1XTLF^t`!^iT= zlAskvQogeU-HBV#U;{I`C|k;Ba-DPUBuU9NQXhS>!*=uj%aLBtSERBGEa@8ayUn%J zEw7aXPu!+@gd@4QgK;c)W{@DMm;NwFU2U)jp$MB6DUE;Pj(J&n^1oy;`=fsU#dxNx z6w-s;Ut!6(-)4Co7C+OhT)qd&FGIL#)2la#(7QRQcCK78Ehl&ccMsso_m;@j$%Rt3 zcbww+qFiWd(c5B|+`;qJl;eG49_3!AjGqI1Cn)2h%(ed=(r@OsoIFhWrY(I`d3wV5 zLCS_#jkTh9XYx+koyHjc_yPTEV;omZ&6*(7$z3Ir!-Bi1jeFDBIe9E#!F9nU;rFG* zuEMLYaTWIFaFf_%{nuk0qiu6e2?dE@fnEvvVGu2|_~9Jlq@H z&7k#Au62>zo0Dv0)A=g`;P3R9JjZP#zXNv`YnDR4Z)qY8c;t5}zx9M^j$y+s-ZmI& z?{_jLS>7$~ki1la!tx2}`j$M$MMckL)XgY-oa|bih&8eUujIEuV<7L3oIK0yR`a`( z3xA?wuI%1H&H9jh$LP^zqfdRG&V5oFkPSZ#R!~+?MHT2|fQ3N4aUoB>lci z9NFfc6JO}fmpHD`7NIk;#PUCxb2S|;^@K{pIj_S!IoaFvhcH=K4e-A2)4AQ!IntWZ ziPr!k+l63{#^TeV=dxA|Zp>R*5=1@dXu($9BOQO5JA-B@8&>{4q`!TcvZ2VO9P42M z+E|fA3K1Vw{OCpu8`Y%e53y&Pwl?W&E_GxN{w0NnHR%sz*oO_5ON}MdYXy~a8JG%E zT>GhaP_Nq^mvqaeETM`_@;YINmB}uyZD^7~7d|1^h^}rzFk~E2th`mKGUWuSB-;5l z&6JD>J;h*73ny)8Iz<91p_<2x?-af0C*&QRJ>D^LehQ*?xaZ+)-!iq*6B#8zs3?Mv zY}Rl_MOijX^=?LhX()Ll#e9juM3bJzq&kk$(=s!c;y;uMZ(%T96)0 z+{XOVKXGT5`PKj<^?WA@{C)NFSDeDs@2S7F!28&(1zQf?x?)MItG$x=ijC&Dnp1zX zcu2C;RSa!(Yjt+7zle~l2;g3`sj%wv%Kd$@ro#RlOS?Z_UD;jV;EcTpp$kAY0NP4p zQQ$IqZRYubjRdP|1(tsX3=e^=m3yh>HhE2`=|QmlkY4@W z#NKas3ZC_H2cP+AM*WPVG=VM_w^$p59io=fwU&o)hoHcs{w! z;yD4v)42Y)yMCwhVErzqe+Ftm{j1JOOrPELzv0?7lLU%kSDW3?g=M3L|_GL_kk4kKgA z;u^G1L%L>Cg8FmQ?U}jKf4K-wKud{sz{|3grJ70LQ4jAju{P)?5VzS>y^|T*^+#xS0FF82wY+Un~4|6U3<37Sm?FT{gzCI?T2D4O1LxCbLanJjF3SWf%V1)hv?SM2lOs zY91kJ3w7TV$8FkgkAIQ+*736=wuj%DFk?SG6yaVFR9R0YnUEMePs6)q%*GTxLV?^Ab& zWvDPGPVsKKTYr42BRl^w8WcS36c$2xucB<1|HT46a3mf-uJ=&hw$A88lK1)seaJLN ze)bCBCZi~dRPll^hRA!Ff@yrddt9GC%`p|tx^bFgPX6ZSh9MP^bRPUfn0VpLfQR|? z^+xq%Z+wGpD{y4r^fk~^%*vEcxbBu)YNjlJuL~y#*_Dntyu&6pp}W*A*f*568qODWtm<<-j!Mr-HFt*cq0Z-w-0 z#)qYnx|Hy{g~-55cz2L@SMg3vQvLd$3mmPsULC845_s`|uCFO{JTT}tI-*$jF3buS zUE!F0kw;OcHYguYgGAhj-=K#7@w%nel(4vS)sLrH)W9_TNlL1R^xi8RSK3xZ;H@B% z_)155-U8glvmCWU571iZA2QUGnoanBxXv=aCPqojr*Av;Wmh^T49!$uy+NkF z=doc4S!mWDuhf5jrDI|GwV-7zi7$2=?{_^iETIn5=o>5b@lMBlQy%C$9VPf;^f#T3 zVOl2}leN99j+LBGU~oCt;^uN>MM$PR6Y^){y7PQA_HsB@1-|}Ff)4U7Cfh3%PTq+& zZ{gB*)YFdDvn~j$Su$>kIST9HB zt(XvIlJ~uyfwWy*TV786-uxy#IY3V=ke6PZC8B{F^*~@?y}cK%rSJajH_N;P!nrh(uWz>X54)-k-mv}>1ye?taeMJfxOI_9*b)3msl2qAJ0uGG~pDRPvnW%}|W$E9kOp5=0+>c1#*Bn%ZS2x?qb zCq3liC+lgwr^u0?;$cbVGuL^UWi&<*5sex3rcwP?mm^2Ne12S-{-n#X)n+0omTa5A zkMz04jvo&CfZQ;8=We#mC&i9Ewx{^FB^Y|K`A7P%OC0lu)b81R!zP>9_6B*=SLowQ z9r=z2C?gc44C_8AzT-ygt4bYLY1`|~U3o5ows)HvU*@-D(z=aLPkb!b%bN=QTTF$$ zH>oSd_|*c2-LWM;|M6=AE%4yBzpH$^eBIP-`+ITVlR0+kmgOlDG@ z^W+wDVW8Dq`03N;!rvE)^nysWTWx%+P|-HD{IJv~tifZxwP5p@&gow4M{VYmKwJIo z1tKL%LoM}xa&D^svvacz?^}JnQ>-Y(^*?aB>K}BL*AEUr>E;PDm+uxjQG7^g{65NW zHwt=Y(oSbq(k>7x1;JUKycbH_yJJnhQ|3~T(n<^c$72Gy$4d)7eY&*pIdMspPkFW=T%lt|2U*|3m8G+s_T^$t|`Q?YY#O@C**1or`k})uL+hOF^(h6fJeE@MVrxo4+t1DI zp5rM6(-P2sN@I;Yl3J*AFp5x;=d-($2^3`kRSd&|mWrV=&GY&0L5)*#3CiKbz>ykd z-n2~kDU_&*xok#B5y4(ORlP`h{rRPy!<^uA@-9IFL|C0ORf~JExv5ERzLgbQsM_}} z9!U;!rE0G07 z_u^O~HQI1651^`u2q0>z2s&A424*ZGJ>{;BLp=YG9=9w~yB6%iIf~Dst$u!i%;hE! zUW`g3oVr3y=|2KINdmJZyVO%v9qw^ITEu#@W#2kF@hlzIv`CLbU}gCRX|lPSF(G?j zlhtJP9;L@}_ou{oa~*Obqhj=Uq|xIc`@z|5A@< z0;f730iBM$HB;G8l}-6`dn77{GN4!L!n3`S-iT*}@a?X}0Fqu_0v&#t`x))1EBy9W(I%S|-IMbO^amGm$!&b?OV^d}|3Ubapf&h9D@;7oC{jfU6Uj%A#N zucP5PG+ai*4jO)jhM%F~j~@&-42;G%jfNBYKc?M;Y;EKG1RBnWG<@07aKn;qrvFxg zaN`_uvssrd((vM+UE}GV5FWxFR8Q~NX`cHTN;t~4sfWM6UksL#q$L!J^@OxRp3vP& zM`(i5aU-4iPGrcxG~ybINQV5s(9EL?Y3D7n(Zl(EWh)iXO9Q9?1egJo2BMB&N!QRh z1455HCXpX~V_{v{)@E^4ek4ggE>=9f=v2e+AfHzv3ft2&JhqEt^7gO;a(lNFd#Y?o z(0{k9qX7xQSG3$?XK#zOyoVfrevtDPn;Z4cIgT{#mg*&e#l%*W5W+2q$KlNRD+rG` zO{|!ILRnswYO(FPLIMVem?REuITWFasLW6lMS@eLMGIMd475Pued@NEZwvTKNl90t z97`+3KL(AI<*DGHiSD)5dd|_vShbjc>ipr`#&{W8aV3X|J?7h26y2yUyOgzGv_M_v zfRz{BtS;;3pkmP->at97Pswu?t+=IL>Dt5wKE)*tcp3s3&y!7<$V#~xyA6@sQ-4b! z??(^?)cAUW1N?S{uyHJeW1$w3NxbM9Wm%jwFq7p<*mu!u%CbsGO3^49I8BC>U}$@^ zkq>BOll=jYXyE^qy4(}XX#EGLn-}dWE_@ARQd`AJHt~Ezi>WO{5}Tt0cSsxGRF~C4 zL5e!~2DV@M!Q3Ju{cBWwu2JB>5#Tw?!@%j4xeJ>ClGtn5nz0$Qd$z3f;NM&(8uckc z^DK#Ln*22Iv^2m)fGz|h>C6JsB1B>SJTfY|cvv$gB5D#G-8OEy$8vG8N4xG1kQ#!C zM2MN0Rb1$mY^`dXdp406iy=J=Il6Ut7OpC8g1Us8pu__qS59T9mn=Rw8F`ywJUoll zHbd#?ovZ|Vla$~OKxX;JXGiSO3v}v{&qA8fEAP{hUSa<_m9&Olk$^o`31U7^&EQI6 zS(LNUL`9>mgm{aExKqA)htF&ti>>`XC>!nZebFAnJw%vt!7-%gJIPKR?RmK4+J$?* zi_^3H*b=1YXgPxbt^KtY%UI|`10EwFgiLnoOG8P!w?dNS_5oX-V z@Plv_i_vSoB~YXN8UNaJWoUWv)rDeY@)O)sp;{JKC2njY0lS+!1Cq~-fT$0QKnvy= zV+61r86&{AjDVPh#>j}J&|~t_WDLi?E~CZ>bP|Nt%XSw6V7WN?-%$i=de;mT^YM68m z&&x-p4fUyZcLmp4AIZQ1!&a8HR788L$M8GDrPt4PjM1>X2?>;w7)IOXNZXLcK7w$J zXN<0a52O7Tp7p;_=3&7f%i}~_FZJ_7~~1g3+DH2>|_fzp;;WTDkdK&WiXMcMDhE zTe7$f08ZdgtzOCIFLrr>&_ZhiA@p*&q5(k@2q^-Ay)Y00_k?NupRh)Tm2|JrlShByl{o5A5wOhu)nQaoF3n^S9+qX4z7uy zktpmIv#F~d-WiVPPVs&Q!8i7s#)car!*3AXg2f_D$tJXh%Jy4AH|+N$r4?8);7Z|k zb6P=LPFlNXQQC@^>eB+UNkra|X_6Zg+RR@!1ltAVa+Ex5Lbm!}uy~fkn5&31y%%BE z5dA@1#Qk1W0Bs5KzUT|(vb)&L==ttjRqH;-Peb!$O2nRZH6ya~b50$ME$~PjzBDX5 zUesSfpv{~XkktQeAoXW?j|+MA=T-aHaGz|$=PUMa;kk55%6e`}ThVL2#^X&&YsVO) zd1Y~Im7aYktw4@8NTL7cTLQBe1E3l}RRGyK0EkrX-g1p649ww`MYtsbLzuBl<2NHn z3;eeu&nkH)7V9M-E+<{ykq}b3B=|7@NN8~Mx<$#TEK17j&>K-yWYwdeyBAZo>H)I{ z3(1#mSF{Y0Scb2oiQR%m_BAxKo6*qb;f%Qn*VbUe?=SE*=oU*gfbXFZ zIr_4iDTuzPrV$xltPzD}%)g!64sBfHXyH0XD~$=V<-C`;1sQVBq4_Jkt@DY(80Ovb z*!+U>I;+=%kR}0sE#^NmleN=D6l$q|&)G>N+8_8WZE1pqG$iru0yK(18P6o7EVSE% zzKI@}+qgXi0M|=-I1Li48BsmMXcxj&-<*IcG+v0X&D{m%N>3`1RZA&2fAqD%?0 zs-=Fz#N7!BA;48_4RgEe8RkXG%C<#H7l**+O0-*OpHeuh1~Q6dkO3d66Y+t1-+3-2 zL2TKZ%oPEDj?$P)p0ZEgB5uj1JYNh?s~YTj)M`a9)Q69XvtzNk?q)}7!qxX<)kc{! zTE2Am4f=oH?8wx{o(KMkEnkLz{F9J=@*S7w0PuIhWZR#BhtZ2g#I0PyM^&qneead zRF<70+Aa?O2LR9sc|UaqfOu+Cy%VH<4q^zY<0*4&3xgE(;)=UtY5y0*9cSc&z%e_> zhZ{~dTp^Pct#vZ}$4rCT10E%cKXK!>+1APzuO{H%LQf)2uK#aIxl zap}38EkpUgevPxceywv8XUh)a0PU_{?d*Q2#JA8y;AZ^QzMc#+FxpJM5AE0ZDoG1? z(0r}U_YK=1-vU#NuS$>mx?|c@7jb&4(YYTPhUyFtoas=uZ?vOLA=>;gR+&~ra?VZ6 z>Xq~|LmkmC+eeAjzx#D!#By0`ZRrMO31&iN)HqfWr!GnmcPv);HRf(hCOSNmiqsNl zgHj1i7aypY!=-LptJ1ZlL@ksF+Ejl>;2@N%XDpdVVVy3P@MooJV;x(p7B^-cF3v*Q zT!nkHzJ6D}vyYdxKU96S^_{%j>1?RKHn57EhP4dfYUpbNKG1qhqj~yuw>n0+`d*0j zbU$nHWY+C+_P=EHWYz6FKvE-SB6(%i?Q||A3!+$O%D`3TqpHVL zXm~aCJ-0e8DXM-VCE??-O2>}Y>0DtuI?y))(E{~N0>4BZ7ZXM~vX1p`YxAjLHunS! zgw~Z)18*bmyjVY4pFH33v(a?-0Edq@VSQ?07>M55K!7y-))n+Ud%u2azT?I*4G%-{ z0o((@jh-*Kd0E&OC&a|`U`oP`%k;Z$bEFOjo-C<0XmkrWs2GlfMyL1bKfTQ{W=JqI z?3wF&K-n;9nf}G?Skg~WF9FZ}nUMZ&=34K*-7(VC|IKibTW_bxwdXC;6)rNCMq-%j zynwbJN%h#}+?{)f9dOBcE4?(J5`L6(D-AtwrJqEQdGHR92@u8YXFP;zoyE64QlwXk zd~n_()dQNwO?Ph7ub#KkoCw^N6~M)fd7h@HM2h@biac`OA|t-3kFIoFk#OI6->MM+ zPgMfo_VX1P^tW$B+!I=Im)u<#B_dO~U_1Z&n1-=y%j^g1;~N9@gBuAE-R53J-ua75 zR^D*X)HoZH;kkCqwZwvmVOH#z8RXJ9uNhNONIx7lW7^rAyRy2u6kWX zfw%;A!XFw`cK!Vod$3(M+U~dS*@8#W>JGg0IM0`3BD)gx`4{y{mz<7W4x?Sdvxc1& z?W-Fr+H+rUw0r7@w@Xsl=h0fo;)-la6ibF-rxa63 zc}*2gk+4x5t#Mgdjxvu+`W~&x+S{~TP4&Kx85&O6NP31u4c(*;l_H5oZW*O)t0o{o z5)pJR!5(Brr{|L&(^*)Jjr%^U(s&Q*xIw`>o;%P8+FwB--O$Bu=nYsYlu+m+SO%+boW zcPU2>>18Hd6-Qn{8e@&W0nrGVO~H_TP)EqF@nZx(lK39S_XxhPI;X3-9pvSI7A z`#jaCj4MzXt3s9geH`d$1*n_AlGAkJdvxNuR9As4uB~(;4n#lwhO%uwo%kM|kmuLY ziLG>EsjXmIKd_I~57rttGG7Z2+biK83*a68siz}{m?D&_I>dCMd_Z+b&CW@Uzelv7JSV=rIq^uW%tsMEAJgTpT==Q&uRu0b=-k%k4080<3F6OjQ0lkXy6U z)xN6kHKh6E_SJRQ1_;^m)q+$V$o00uwd}>g{K>KHJ##pU^H8j>8>>k7LuEd{Sw(rC z*PcIlM*FE83m!Z*SPO#P+8oY2f^lu#g9IAQ@HI3l?fzE$7dw^qIxugj8yu*mH{E74 zs=cLgLJ=qB_*TBIOnap#v^}0FcptaKeAdJFSPwF+GSm!hV>#sjC414 z-7U}ou_E<8HoILT_$S!+7RpThZ=I)d@J-ejtl)VRoA%!2ATn8LZ!tefk4FVMvGs}k z=LdTU$VXqfzQz0}=jzEsfIk-7-e$fdaB7jIP(ge;SYJE6H^bE7&rpLWlhoj&f8(GA z;an<>yZK$q+USnGLGy`YO&LU}mHkGaVo`miVD=7;h+FAv<`Hh8Y@xB1!cfMbl^Lyr z+sE-7VwE4@Bg9IS>ys=%5njLg+~E~PTgr{2VG$@i=&tRp^H;R(p5OkWS-UMD;^)AM z`)J(8PLOpWLtRG1TP|^ey)4W$l{~nJuznWj9tn%D{Xo zw0~X6)rGlgL(^)0@0Gyz*hHV!LJ&KLffx71t0bD=*woFuwTy9n?%%HxDYdbwcSLc} z|GpaZAK-s>4NMbeXuPyXHbgjf|8n-x}G?xTL3p&XS_GwH{Tm7w! z$n5sz*-GJk;+is>I;@ii8^h^0mvmG;so=C`VCl z9&2=Es~Fd+}t|*E;(iQi9iseV-_#OCUNKzg3qhVx#O* zmk~&X_rkR7AWCZ#Nq-7Rf}oZ$6WhJGw4Q~0GS6bj$|zQrT_-@U!7?nBmtx?6V@YE> zk*}jb-iZLoSVn{l&kY!?o7aGm$LM6J)*7idGx8hb*u3hd}Tz|o8$do2p+Qj}7H zN)13Ppce!gD3}YAgdla0?`Y#3``O?hd$Wh<$NUvo;D&8%lGW|6P=ZfUFIw)IDC|23 zY{GM8z-BQ^d}(*77M`=Gq!3dOh|bxm+UXkx0Qm{;PvzX~@uT3Z=lKhqjfpn!c(j4- zG=RV_4G5K-%%_*t?`QSbQHL|5UX5?oQogJiSvw;&{t)%t0Wq28-!BJA<>mx5Jq)DY z%_dLxWVR=ZM&{bZRCFJQ+wCuAyA5va89TF3SoDXomCz7A+pnmX6wGY*f6$_Aqsn1{WSA{W4v4@?Q zcY-bEx1D`g@~nc7Hi13=5+v9B-%kG`9-yuUgv5SJSnMr<&!+MFJCM-U63-kh0*{kI62wYirvQf_?1CTM`zrt{&Vi#IDCSr0;{e=G8T z8$v-9bUpfzosf*wa_nugyzfOg?Zw{w5kO}l+vi^6YG=PsY(?WiM4Kq9_!HJe8o;*; z8>eCaTPioDSdj6^ds)EzuU0m^%ezNtR4xt2W8U9Ny%4&7fiZT(Ad|m-SPa^Ygq%tZPPCHw~X_sG$k|_SB7V^*)4YOyKigC-`$b5?%rCUI)E>phry5P)*IRnF$)N$0;m zSRRCO2+XNSiQfdC=u9G44MT~Wq$s14QxOR+pIls@ehcX}%tMm_~ zp2y12=0#(dB{`GQ`>u7hm(f~~W%(kdZTPCQ8Q$=84fl%J8l65rxe_c{T*WBs{XK=d zm>^lN6bJRoTI^|>Yj;dzJi&oS*xmO#Qqag$1Zt& zn5#g>a}#4M<0q>zI*w9CSj1+4C@c$=+5Lq7zg8KJnjeSoy9FdN*_E{@t#3R3rjR69MB-{0l8yc@??B}D z-TXeRjparIfbXau5x5V(sGZ*JcotVQr1`}yg&vJ*$b}ZI;gp1&-?ri%woqmkL)bro zqeMLG=T?T(tj5_jD+=CFl*`kwl+HG%k^0oyzkrzOn;6NN{NHab@aD1MwtTGg&6eueCT+#$-69|Zk~3{3Cy{-@0z1$`!3?sbl81nTE#g&869GL;unNjxrX zR^lRaZpxFM8HS52Yra26>5yLgM<_v2=LA9djT0{TJ#nujw+<2qCL{B! zU`UrUHgeCZ3KGJv@G1uX6A!Y?we(i%G_a#Y&S(KiQ42-Rc;(O+y5WNdfs-I0>mr$Y zY0%f9AF(C+Ab$Rha|%y_#LOFAD{_l1`7;w;?U$o_g1xbi*%5HgS5OCNlZpDT1JA|w1sezCm|BDDtBmCc_hYC1k)E= zS9<*9OR!Nv@_g;Ze%KeU7>D4*Jm1FiO2*B)?h3IK5)I6=JxF~oTos;!xB(9OgGhjL-mZ%dKO0Td8^X-95 zEG}7CT(YsuR4_$iY01Po(^GHpiF-wk<{V|Z2<{H}LE3`!)*FvIW~`;zKd!_h>AU)X zIfeX6o~bODZubn|A z5O8HduH<Q}T!yp|c0g*ZX?nuk$H%Qd8-rnO>Uc zxj)twl%8g&T>Svi;s5ZqVVgxU{}4iBQmy&z*eqn4M8}XlF1tofVRo`}E;voGap7q) z5Ams}oJ^C}^~2~qfw@F}$etD_XBs<0=KW%N^N)7NSl-KmH5#qIEwBlDRA$|8ouSY$ zkL->%b80~BQlg)(w)=mTfy1+;tf24@?WL~)Mm#~7>1eWC9_IQ|b~?;aRsaXo%N z&u&6?lYL-AZjb=GNiZbgehWm1*(9h5ARt6(wHgAga%nAyS^;$vqBRh_tfG&Nwn;#1 zb}d#bDDg%DtwQ4k#A0h(Nxo5mv_MV962dP0YuXWVZr@=BizV#0)xrL_?zGsZc>?v4X2y9vanVQin?ACo5& zQS<)bW`9kwH!^HrC(jv%tnke@Zu(D-Mx~G>L@M|NX)?jc83YSTZV=uO9fQ1&BVCr) z$ak59B#%r$qAZD&`CmxZ3z8tlw(yv{Hn}0S6wjTW7(PY@QHn(>6R!UjC^MY2{L&fj z0RhScB|rt{PBSnsnXv95PXs~H#G&cE&NyZ915miTnctoowWSmg#pl88L46v(1KlF`iElD`RgpYz^vL4>I$-lZA`wmcS`C>_}K z*sLUHvy|$F-Y|zql?LM2s-rLTy-H1CU1c)nF zR_OC$V-nrX_w0CDI*fgSPga>A6L~iH3GSQkF+Uxr9R27No<&R4sCKzP!H?{vqIzw* zM}1n{>O&EQxTviYWwNSl5g{EP#LW-#E*?nY5L(4^BUS|h_s(8QK#Z)!`haagx^Er{ zv9wVli!NH6dq#GoCubIWPce61Shb^BpM0Nfn7OnzMPG8C?aJIqU%?{GkYZqG7@jtc-hCDPvRNrq0>7U+bOP$jNF4+niJn2ttH$O*+&9xA_7*<+6#kCce|IwMU z{fJ^8i{Q#?ni~x|esGkbO}`i}a&u;csG6oOfbrZ)hGDpR0H)gzt&BTBCyvH$rVRPikxei} zwYR(7WK+F47O3ttdE-Z!y>_P(=;KuL;|8nw+InS$3BN{zv)lVOv-0RPd{JpVe^_+oTRK)9 zGJA_=O7hp*70->p+izEv$(Cb(B6->5@9&52G)E+b1mY~?*WBN=BtOdPS|P|rtFD%e z3}sJ^%bfe{zwX`}JbnbXOlgt$Z*l8n-newrK?eAJ4Q6HnNu9npt2H7XpMs>%P`rN}kL+8F*ca(YFKlQZ^o34KH`dRz1csj!#_GDim8cKFsCU zsHQshgnPtWXcJCaOZeUP7Ag{?+fpU2UCNiT(I_Jy4NRUb;%zAx+%{36j7zb3EY;)h zw@n+EL!CKTNJ(9YeTETM89EekEGyDikmah#YZIz)IruH4sv@3qX!FaoQ>LHHX^D2R z>5@QiLl=iUqj!{jmzK3ykA1*)m%3N4e!wouzqXB2GcqR#KnHVef=_2zl!|vuOq3vfM%rVG^y?qA6^mWoBQ}S= z^+DT!&~u7@_(5AD&uXo0h$-8o=hoUXlmags~x74k8@UkWWRl2+8j3oNOUzGAI~7aLuos{xwYf? zqglJ_i{kX#*4R>G4w~Z$*Xqz8Swk?*JMQ&8z5bi~_AXEoySFnF7?|Jx9p)!z7GWT_ zy;&IC4HK6Q4b{9Ej3c#MhA8DP)3VW7yHj7zQu5+&A2?@}QuUD_Vh53NAabW&+4CsK z?C$6|ZqM3N-^R?uae}^ljV*1G7!M};#Prq;8kro)n<7M9_rs`sIbhb_xNK`qoc_dG z+jtj8nusVpaV_kt95bgY<*(q|z6>fcku0mlP-HZ?x#5)Y%$#>Ql|3GCX^JukbAw#2 zDr>Hsy=)7Yf1L8t)(8wSh1ft_iwEyXT|Gd_%K+YJX)7?}rLDldQ{d9pJ+yUn*7o`$ zvjKn1OTgVPaB)o#yyk-!lhv|XtR9M&f)^^w@uR?N6zePH(haKKBpap89#Rk~al7Oe`M>ER)~^ygLOR_-qHC;=zBeR}FlU-QuJq_|&ju3O+vt zp9b);;DT_!;DhRBJV%9h5%uf-tUuRxvO>ub(3kuXJ<7OAUaV^kTCur};1+@7$HewI zaC>g>pHm-*SMnYJCh<`}61Y*WM)&b?0KZd!gWR7%?*74lsTYgRODnk{jA#i)`-14t zKy(|3?!>9-IYAV;VEil)ou%w~AnPynB7pUTAqp-*@*w3SNdBysI}C)i@%Lc)&fveK z-eOboehZK@OmI z3Q&-H2jt!v{BC_w59F|w4RtZ=p&)iEhy|sKnMmAU3T6YrY$=#6RrWlX^=^H7m~^AV zNG$+?2vU*4rR3QHQd_d#O+AHi?g`)w5u|_>BS-=7PJstf?}F5qgZF;ht$y~6#uzXW z=TR_1sJ(WZ3{IxOR~Sr>XYH+**fbwA=n|PKWeQEOp&MWt?K!|@peB(|`n7}>N05xb zlS?&TrR=_}zoqWG5R=DQ=0}@>p8$f7gV^4Vl#b)evp%j*uTneynXqs*i#QXZm{E5Hg1d8yh8m{&hZ3Uf0xpP#fHPLF7YQTdZ1B-yXPl>Nj* z3QSFiHY$|7WmF~=3!v@Sx7mAESU6vQ(PK-^o)=TJsf-HeC$tv$slz15EgbH#hv{H6 zxV&wae$->@cm1WgZA&{IR5w+cU7HT1RAHa2Y|44Rn0vy|Dd3&p0^^O?0QU!L z&S56ZBVEch&1$oYa8y{K-~5>E@`^^nH2&sjOrEsrb%)X-hKms!3W^bXBNTa;Pp06JZj+5&aBq zX8ZlPZOB#Z_R13piIRvRe-eQSM4y>RR8MnT+8 zY^e8}tLy#G_cfS$!`QY_E5G+G-uRX=w$1+Q?|rj}%8hAr&-(fIKK2O}9Md-HXW#p# zg$j;tv(NwDH=FUzG^WkI_PU}nAJ(?CFr1!1`sr}`{9$dQ#z?vwawhWOoA8GN!`fy| z;lus>1yCGF^i^Y^04R$z|MRoGg=Q{7->3x6KF{1RL=ALhCVBnOU+bM}<~{NA?^f6RBb&H6O_%_2PKMtdoIhZZx-Ag`ls)JNYd9O98;N1Oe_;d<|pUm7wd7@)z!9x6#ce06j*c=yIf+ zgI}5g3I>S&A+x=~gQmdwH%x)9B__fa@HgaIEN7|u219}UG`6Y_nQK*eTuS* z20CAtP;Chw(`6EQMDYzr+p_-jl5%*A!yz#~EQ!{){U$Ee-DdXG#S_3a#^pT&bN~Jg z&fX~4i6N6IlX((#L4V2$#e`Bq5d}?~3gACrq!y_}$b3#$0=>y=;rr>0u0TNSMBc^Z zZ#j`OcVOIw$&deWx*Y3@^Lu&6IxFfP>_Bwe)3*NZ1-*$$nd%#kJDLCaIfgazyk%%? z_Hsh-Nm1a?4$jt=6Q0&&X+O23IN&hHcBi5$It!F)X5bEUZ_f%Y96I@KQJ~h8AW`K! z%TdcFUq^^qV4R`$zj4y&h*OTue~H*qV3|E+iucfdH86A)RxD>|_MvVykXth)|C=3Y z9C5&BnjS?72X9uxv^FN{lf(JqWOa8vbI`IX&S1c37)*Jn=SR{I)IFax# zC=Q&xwK#B2kmYQqYAF}M)U-`jYqlGL@Bo@T9Xg9-z!vZa)+R9UOa$pIOHacNzv@a17=yzri0R z!Gb^W*eQXoM@j;R9xn-e1F$%lp^Him0la%8Y(3T#SbM*+c^C-F>7aONKBUIB4?}bD zu*9LcKqL!m(aN0*B0+cB>(8lazOR&tdE(_Dsu0Xg;rg6JYxysoEhWkNN6kQOo5gqW;b@M zFz!EHEzW3SDDBl&U})D8ywM!+f5P*ZMS%ulk?cg;^)E;Z#Z=0~@|U}+#O2E0T_h@B zPZfvYCf*C6Z!L%6ez<2TmB+>t;lc2EAhs4;`Bp6k&O?)~Zs|JbY8ghr7>gR$eq`p9K=4+O9?M@1uI)ud%VuCa zB`xK9U2uoe;-4T^prw0*Z}7~~Pbt>F{y99O@4^9NaVh3W!Kam$MQ*HkyI+g-b}>_T zJ;E6<_DY8~qMB8;_O2nM)uE6gVn^2-ink1-2SnZgr=$TzX)O??wlOYXyMsIW8i8pJ zv&3|atS%Ve#vvwb=ns#RLdJ>fF-%o|N!m=9V4BG=Q+1!4V+uHkymDx+NxbpOF*#jU zg#_xjhF`QMVrJTjd;68re*j#F34lb~C?f5M!^NOwBIWS<5FvOlDdjevs*hp`sIeNg z={b;}i|ob)n?~7E%AVsCPjtd^pyRTzQxae*o6Bzj8K+-80P#%#y#t`ZVL)Avm#uL8 zhS?gv;cA3bKcpAF!NfGxzgKA~A#Z>3Mkr87WDXaNDXa`tT*Px1-Fs**Q_tb$==xL) z}px) zaOIa3S#uZv`|iDEO;(P5cRg;{!b5)lv~X|>_+lW4b+Pow=#b0$f^Hb8KM$wKXfPp6 zA$vdRH#xL6(qJMn%yLnPAjai>s39azW0IE3hW*^==NRZZp1xKom;BsR=HHiKK~FH1%`6ouavJcbMQc+BLEfo>AmM# zmxjvy;(O%|QBI=%o?@jDBL`H{Rrq0+G3aUkT z24C|X+{L2B)}d6{lk?kR!wb%s(P1x{+hh)txlm4_{kP(MpQ=pgY%dC=W4`2nSI>IZ zHq4#col{*`UqK_X(a7iGkP*9R@tF$j7~0VAG3|5L4xY5|TWq}5usxYtL~Z16Wj&r2 zU2(=u-YUws@WfnBQ)7w@TZu|HD!+_q zJY>zL?ghVqX&=X6LZpe3z8;wgb2t`|*>Df8w-ND3{NuVd&dERYNtw7PVV~SrgQ&*@tOAf%~ezb30eJs;rIi5IoBM2-g4t&E1PV4X9>&~$b>^tkS zUG?#2-H$yIYn%ZuYen}IM6cS!=yZVq&*T;#dml-8Yz>Th3$a%=$<8K3UT8Os5R ze2Z0+e6hjc<`gdREi@%{A6Px>tf+!!K^1X<5NYNJU7U6^k`3-WRhJ|05zant|1>DGU9ycfSfZx4?Igz^# zj>#n)<1wYpj&`jWi`iTRXM+6n#MymTu1-zt0sK`Y)t&$)9_c=A$qWO`2VnkKjQ0Kq zKm*>w6T!^{6-SaN3LM)J?frU*(juOYT|mYkVNY_`lF z6NzfVwMO8Z!-y7;Iah+;9QqZq-~#@xo)S26E5A!jfhFeNb%%e+^Kw(be@-#lOpi~@EDoHT6B0r@L>~%UZHMaLKq0q~NMg_lS+0UC{{2OPT?kD= z0SmX2Nkr&`UxX+jUaws{I1S=fIsY59T7IX&s~7^w6Hz z|7ls2EwKy3<&kb=CtbT7&N5s zi^hC~Jg4Xu=^jMwm${mUjY@n-3@2>|7q(3PT~N1hkk^bJ^Bfbgg$TMkO*j=v#a2%H zleq8qF0RtWxyU0_>FBk6vb3UOQdNOaCzunWPRFFyg5^+X%W7ip3zgL5x_ztBx~WwK z!Y}ENR`?|}DW~~{V8m>3q8ug6Wc9P$dQ4p%P|`qnzF23vybL66&mwd=Zrh zK!X{{bC!AJ9P`NaSPNbOWrUeNrF%q-7bBoaYHXdPt%R}8ktRcwy@kH*mz=I6*bnbb z1Hyv6SdRA;?0wR>O9cD7B&~;F-z4t{_B#>&t>R^YVE-^_G7$?-XOO@BY^W00Z*cdV z4;a|YWJO2s}Y_F+-L<|;dv+%euC%8Sy z_}mAX+0QV`2N82|Obc|y@EnJSo2&q4yJxD*`cRog{bd;`jjP)xH?7`2dBf@*lfA2_ zoH-5kI})l3=CP;jOsOt7kj7SWlF2|EzB|^&*0B<5bh4$HSA)+e+nNJuY#S%Jivz(0 zra;r`>dB3(R|BwSa`S5TigZ^syJt>qJ*$sU`6^@Ib*u`TyZeO#4B^uyWSq|z^X4Z%ZEPiJSO-zSg(pwYu=k8zVGUIWRe2~lBY z$U~(lOVR7{ttiG7xSDSry4RFH+V$)d0q@M;C2*-%U-a&G7GT&zIL-8`+FtE-&&xU6J#1jY?UTg^i6$0cJ5+I?ZN`TSyS>Um|mer|}S@bNuvbV1mlAOWWj=8?B!6 zlhi;98u_z@c#6SYh4^BfnZ{U{sRqOw@q|uCAtWDI(2Dk(o871wuJQew=vSiR5*=?p zR9up0{;lrTrHxeB`AhV&U@wWQD_fWTk+e*Z+8r(=x-dht_TSxtxeD{R`8F^~Lc@V**vJE%2F1?rUQlX*Tqb||K8Vx~4lsxY3H?`XD zAj2a)GqHFZnUJ0j1UMX0ay(1;zAK#P0L&vgRLK*XN&5Nxx)Lbhw%-%xIr(lJg*ymg zIxk_zWdD-lKm{t%%9=k<6%{C{^sd1+W0@Fxx!yDYNK=>r5vzi>SN=@Bt!!`=_ZbQaQ(o zn*?5`qpiS7T?fp`n>vPsrYXw_ckCIz6#QpZLF|dCXRH9~_-AW@y6k@DyS4(;vYN>U znA@x;EN42-Rgu1WawqCB>xoj{xjJ}R_2jj;Th4U8QB`0)rkv^6R$cJw3Z^j_w*%J_ zl^Nu6F2gwg^>pv)xe4BH;ImUO@!2^nmwTsq1MdA(y)xWRpyH7ajYJ4C^+CDHl;odT z6xblqfHvaGB6=f6hb5>4oT!R2tW^ciP?7Sb%0zDSNQ)B=s`)x;9dB^1H?62;z}E?0 zCE+0uzHXDH#GK!Jd+8aq=Hbao%_F2;zsW*3yDb~d9+eA^yEb0ywQlU|?P9KzjjwfM zqF1^laU-V`sHamlauVTo1(KgnM$1fi-i@3@Ac3TAlIz*Sa^ zdCRLNOXK8OS-y6%%(XK831wt%?J9AVVB@3&j&I@CTC~U=P(U=0H@pnir|DZ) zqj-)m7+y%iS=2|1+$d5c7KaFijokYEhxpKq&Z6Fa2u;>ZabzoPVso`OF8^ z${um7%VhKLFyDFih;;E4K;MHfFGQoi7~59R7+qB`oo^QYMCZ|f@L9GgLaIx*ut!>?r64OL{ll`8;m$CxN`(g~D{De0n(_xw zo*kl$%n$QB`r6mu7;W^=Y=!N9FY5RP{+M5@mBzsG9J2BWW43!cgb@jc_xx}TO2<>Tv`D!VnAFKkp&(^NBrKU9_mbEDE5prvoFQd} zr7wZ?9^Z;Z#QLiM#ER?wh06N>+@P%goIlyalyR0GF|D*uQ zlpi9t`!~>1w{5jO>)y$h%6pe<#cL~FE6~O)n~Tn&2?=&BXGjf5t|dHoeX(+{=vDnd z_1(uEU%`giuj8*nl>a5NyP7)G)e=kO_(SH{+@LA8+e?swscErw)1qB1Vm+v$2N5G$ zv2m0e7{y9B{OrcSF%%IATX;Dg@g3$*nsqsJp2S~iG)gO+yY9wWWG6m3ixaq+0ZDAt z0_KoPoCq;j=I`1Ht1?>j3^|%>#jcCNad+M7_TE!-^VTC{Rd08k$=jL6ZCo`2&p7wW zRklcdOdI|8j^)m#bY*k$PZe)1mC25IfL&=B08N|CS9o9LwokXYUw0K7ibYiHmx}ea z*KLkyi7T-}|Ih3A?DqMDIi8S5a_$B#h!Q6DQpV9xNtW?oDS7|FOt> z1TE~U48?ob^{U%@$6}NBSFvXA9lfHw^YWs-cZ}%e{rTkt@lnNvY_2|L0~s_H3%wvoKjFVn;MA8K>o zv~}BX?74dsSQjqPvr|QB`G@L?GsE!Q^QjeQ9)?qPd=V^fKy^~f?;H3zin8<}^1KgK z)`s(J{0}i6$+M7O$s@ikg215@>Yo;0_r&qF?tB0G^wbMe_RfG)8noWs=>*l@q`F!j zib6$a(z?9_)xIW8t9uc4+>gm&I`1!TDe^w6_I_defgmmcy)hF^^2rq26R@ub+-<+ttAhI%&2#_d@{Q2H;8(9(uXR`-rN&VB+~J)?XPXM**G%@c%&O z&-uf;-KRnJuORm($S5QC14u1o7ApR+?WV0kPH!q9`5&n;sK#}x@(g7@Lelfr zE@W)G0#*98Z(-zY)^B;sHpqPh`AO~}c$i$RghKt$>I$@SqWfVI!7k8&et;;lsZvCd z-rWb$<{YF}kq}fF@-tYTsBuH4+%TH#o7yU}xGRuB9yx+Lrzl$lgEt67DDP>AHjzN* z3uDsiD4XzYP>XN&hyzT5@3mX>4~pX)nK3TIf+-mJExF8iKNY@%(eiAuSFe5B_LI?z zF2h(30v0Ms-IZV%xi7BlF0i{09A6vbYH=`Yrn5T`XXGdJq;0ke(@?v9*EZXH)3D3* zPq*2|n4<5}d$-y$qGWvQqgrkKDr<=KGL=IcNx7aQuBmlvX76NsnOoL;yMyVw3ETE@ z@u;Ku-5KlRB4=DIt*tkF`fu6mD8@CBt%OGq14A)IKM32d;Xs)U*Rpwi8|t6an_6v- zf{jFX5npB$wdG|T!LA+R%6C%Tq2rFc&GS)C!*nR?S{zuwrSq(^mKlNr)wSpsT5Tz& zGk5CAZCqO!Q>ff?B{h)Pc8mq%@>1=Uqg$XYcOI#y4c}e1IV@7-i07xy0nm z%N>v>XKwSxIg7oAcdnToi%xJ8TL)`@Yn==z;bT#3ipxlE8TlA~%7E(4q}!V*s1K+w zMPA);G3YMe&%mjrFJ@xLZtu^co*dHJJ2FCwA>p9?9B(dwuVq#q2{Ms<2Pjv?yYU@C z);>(}95XBJ3PFqxGH}{P6RJ-!cgu|ys>xiJp~SEKGoB1)-aA38L2;|y8(CVT6jR-o z!xhi{idy$;GJUT0s{4!DtNZ@J4fX#S`#?QW6gHn+xM1RP0Zji8IcJ=kr6CKjeOq^oHg*rwo%HlubBN`?^Sw4~qQeK=Nt&puM(KYb3gnOMm%8 zTS`)AnH+@yi!9ah3}-FUJO9RI^@lM#7cJhA$^3G3H%}nJ>rGda&BCFmqJIVmO-f!B zD!I$_ZBpfxp`5yl_x0t*1K_A;v^_O0?_$toMpgx$J2D?T>AP z@N1N0>#oV6WPDmavh}lpeAA>5rfc-d-L|xJl8k0MM~85&LOUnTZUcA0b&ak9CsftD zA(&kg!YB&N%+za!hDx@M3YApz{;QSh2U050GLAxaWyMY@l$J4!*jhquB|B7D86GNJ zMD^cMJ5}%a2s%2mLYbUW6S!4U(|4JSE-4yG5}j!c)8Dn%W~tni5iTc{NI5DAm1B_O z&eW5(a!1B{u)KpiHI%;m@?OR7&8Y3Hq@=)0zzZwmq=K?vG&!E!0n5X9%xaqwk}su-c(N{9%}J2A)!Wfcpl9 zij?X<{+lgxY89>xQhAjaNm6+mW8{Al5WQQ;1wo+zV{$t^#CYEH5q`%$>X1ARY`p)4`fmS%(F z^q2P8EPbmuoRx|cX_!#6ul@mK8z{XVLB7?(H<0fnuhBdDo_)3fi8RZ_!IXTdM4k8D zmnnSdkcLPZR$QaOTn_m?4(nM5$FK$@oFOE?SND8kOBw$MHfl1>8T5mNIsu!_;ZYZh z!=>PI8eLv%qvEybl^@y?qsV12R-%676I+bBMt}M5Tnp6IAHD>C1ZW(JZ4O!ClBfKtgJAIE)P&|q#H9Qwd8s>x6SxR|Ig6r=7eK3gZ zO_*4wjZ4cfniDE-Gf_UyDDPg@Hk%_^j>_io@if@o8msI%Pk^gnlyYps0F$?j?RCc&4Sp|T737K8OlBGxNd zY3@leyIT6ExmKjcxmq49j>%1&H}m`R?Pt%KZ3oQSmgyD9hd#iV>>UDHMZ*)gX9zsj@l1vM*atiEt2O?qAn%QCaq7 zl*_QwqcO{BSw~Ozb3JxCyRjdIyzv$0{)QwwoHe~64KH2x`wb3k zB4&QS8+J_(cLQU1``}PFq)Qsz(8UUyN;jm1yI~C7U{DnbpC@pUP_z|9plE)7q93`J zLv9-_bN7aXf}wN)WIj*ys34AjMi;Q$Q=p|kq+Je)zcdZ-A=UbfPwSsFCHro*Ps}@wMu5A)JvTU%cziRA+XN2)bmip# zoAf(b@_Um0>J&(CP~QC==|2Qm>g6F|a^%{xA5Ta65vP!51cNVAU5|a~{J8$SGQjs* zd<58cu-*jLrQB~V3U%9bRK^(tL)}*Cc7^(_44L6phD{3n7U6s=6-i&FjAdA*K>B25 z&(SdVw{o^6++U5bR^h;3a~zxw<0j4_?-1;f6I@%DXW^R#mw)`(-FuTWUHK1gi1B@r z@$Tc#hGEG4a&7@qd3gv(&Y16jqydES{4J1^07(f0ISY{AhJi?oP=S#ihJk}-`K?r( zLU}yU4F1{G1Mc#xh^7tPl)WJOxXZoO7>5kOv=UeT^9;k2#fiD&8HQ3>|Eoj8a5KYD zDwSdQ5*42?r?hWVZQa6z8z*pM!F~3p$C>(dADkE(E!Qz=ZDG>d#-t^bt$?Q{5PIq7 zP;L955b3MOGs18VJZ2@sX_ITkKe4Qk$wkKQN>l&#dl(s~EsE!A!hSa}JY^(Q4U-WP z;*E=FabwJbLgO4q|8JRh1e;EncMIeEHB*Z3-GqsG#HBZed4qY7G0bPeA4WZHDq7W} zB6qkV7ena(s%Rn7V9$!4?@^IET+#SQMgOBgZUxcu|Ey@pB^AN(m$rzFe&P*IWpiX= zk$FWF0k1oh&8bW_X$*5ovkTw!VWJ|?1pMXR8sTi|%X4(WH$=Rifcw3u|0ScBfaZ*X zt!d#AFo}E#=o|n=mH`vG+HbX8!rso)5B$Eu4 zk7o=^%;5RN?vwW>5ms0W*6{sxkdY{jg}`VeogtksLs|k9HL$o@LuoCNwvaTDA)HZI zh8i;<6N7_k)SIl7KL}}`fV59R*7cC{c}SbYg!DYqXFX(133p2?TX!j2jqEPnvWV&Q zdCG34>{iNdk**LSgR(O9zE0Uh1Hshpd6a>A`P9$uN`^yUBcbJLb4uMR$ap%2ka#LZQR((gyChH_F+Sa*)Xvn4S2v)(S%5GSX_=%;nM+%@mDX`JPGANI_ctfh<=k?8rNC)( zFJC?}S|tBTDiT0*Inr%`K4rJu?(iZ^-wgl7vwN-ji_cMCBbBMp#))oc*!M*}Pi*_VJgqol7? zx{Gh;=@R*#4Pxj6QEXi0z3Ao);+wfwBKJ>0{x9L8v|{mM@`(OOTpXnE>l9wN|L(`E z>HwdG_gAGNel6*CpP0f6iMy~*mhK8Yrx3Ix@|~tVs zK&bF21xKrK?IW;_=HgmUZZBoeekvPI@Sb^WB;=qAMSQMiim#KrLEM4ty+X^e197bu zq~j6SMrV9jpKFTs?dHx3LHcWu-mkjjLSlCq>jKlj~aJse^9TPG~Xj(%&`Xda3^QHn~kz)zlVj97nQBw9M_)1sDZTKL)dl+!rkO` zJI+UWbBGnz1w+9Q#wOD|SoK=6&0ftJ>Vja6+1I=d;ZeXSbXJ`{d< zkMw0wZd;ggh4guL3?eptUhI6op5QhoxBldg)a=s{anVspTiY7p%=y0gsNii zNm}*G!NhjluXu55_kNS21lkxKNqCFop*I!>jz-h|$#UstCp)5pJ2*u}6W-A$6cGQf zoZK5%Gm8}^h@AST>g70d&lU_F_Y-_jrOPBV<0k5y11vt{8h?ydr1$uG}2^uMIr42@nH-6V5LDO%I3@B|KB z$G3$uwDENFw3ERtGDC8j)IM~a;&odvM3e&{8^fysUH$=Hu5%T5J1f33yWac%vcufy&gQpxlB zz<_O}8x31t8J63n#2_q%@-o`IyfaECWbRaCnnrfT+^Phz_YY<$t&f$0>UHi{U*AX1?!x5p9) zEUvU__Tu=pspu3Z$jWM@yQnz6A+?NrvHd`-+?6v?RUReId9GBDw;*||0S7qA&b{L{&Z-FW)(Jva@|>bnwOCjO9coN+cPDbi0=?)gVZaNn$EXxG zVztNxxfUmN3sEf9DZYihlz^NAm(qAYffP!?xmkW8#6o_h43l=QT)QdX41)5_B7~*P zPTC@Prgyz<7eW+62(d+#w4y@>abgVWEPYZpowMZ)e(X8121)S!>n3G=0TxU>&sq{V z>sYwj70{3VG0qu(f-42*Wkh*)c!Ro|q1dXookxu~>VnNW30Gq=wH0gNh$1hr^I}Tm zS(c)c%n(xiMT*NpnoMPqroVaKHaJ=B0pZ}zP-wqrZ_+c)+cK`rl0=Ic`jdjW2gT5dD|E@M?Z4jmT67>9-aeCPwv!bT(A|#J`k?rs+;r|@77ZF z%@=ILuSAMubvRbdA)Om9luXt~FJ8*Aa>3JE_%I?R#^OLC&rW%kdv|zlMM{+D((*jd zu4i4eO?5xl7aJh<;9_Oj$|UZ%3{jU~{}ApaMl~xu3)~g$e|cbbg&Mb{4epo<3piBe zSU1+1-6mzrk06p5L>yvF#$iVzk;#^b%PtYbQBUR3~gZOOK>dS(*1k>7|>%R`#ZjR2j z5+z0N3fhMB79_q!?DP{?X@j1yYnpqQ=E>QE`81qmx;CC*VXDVJB+C1fnQ*?@@`4<( zcaVmWC?~loK7tw<%G42GaOj;GTJN09m){J5!ER8zK?LkjT{k2GPFyESXV$YFc&=PXK86APKmNK-IH@?2+54gze92?NXkiL zQwhJs18LCXRV}lhh3PQ|rbo`≶{FC_)1AyI!hl@0ot~uAXet)|!gn)i;>5EYrky z^}m|5F@uJ_dr=oEv^wMMLpkDiD$bAP-DQur51FB-nzgL|4Y}N`y%*E@&PBcXmZ^H0 zNt>bH8Kq4yz5R~V(##SOOf$<;h_aIra&ca^_WjOCLx^do!Mp8^)~aWb|JmA0+2W+Um>mo$4mCnxq)|p@S~;p0JSUvD%S@}^7^98O81Yx425_Nk z=uXi@Up|?!RoE=kpxp8mJx6~rM!O=}^v*@zL!r`yfh5rGg}>+~i*~vCgS+qe>3)j3oOMld&IaB6biE%$>RIJp;ni!yh#U01&`VNaWTCLMhQCfXh zA86HXG-dbGS6a0ZQI}y1pwF^uiF&J5v)MN_g(lOfnAE*6U0MI+xR^x!s8x%)(jS*N zN<@y>dn+u~d*dwZ=*J(zijPpf>pQUvPp^&f{F>*98-n^}jN21Dw*`VPrQfZ3s(2nl zdX1hRs|}sidBfYY8r_O#LqgT;O~iMQm^QJha^8EujX4KAT#;Z=M9WY{E2km0N#Xpv7o;M^n(a#7gnj>3ne(^#30a_G0|dH zWzl)mQh8Orf~LWOM8KKYvP{msf+aEJPxGFiIe@F3dl?~^N^gd|gX1)3)}|@nk++)p zPRQH%FZ*{F0|R}<2l_2>^wsb6rZ{avLNtBlf@;h8lk7VK33_*&R%AN*XMKcC8)WkT zS)XCka-%S$U9UIZ-8)J5*tCSCT^xEkRH&cGMi^RJzsp3D;R+z#&&{u z+!HvRVSIe(d16(XL)@_F?~1*f2o+uI5r18(sElAR)(?ZSNomn9CTOXqg|F$UiCWem z|EqKgRF_z&!WCOT49&DckkT#s&57C-eH#bAZI}1eui5QQa8Suq{l!ErZQ?(0ykm-0 z2Bdpr%jpM&2~$Io?g>th#i~e(Eu5cVPoU3VLEd8M=l0jtByFTNZ(f3Z2DM4;v3fz0 zmXdVgnV{auYbzIMOG(#CyZs}5S&Ej>*K%_R$nr3dZ*I{aPSUdST|Y^%4-Z4>xK9&!uIs3<<>h^}K z`|uV$H(47qN12;oH;1d+)}uP{@{UyZr*L(R;Z}4kwc8(LAokn=NY~NuT+1(t^*<(S z{rfi0NwCYn7>A@J17qVY`lrd-&3VpQ3HJAxHe_Vf_NZ_t25FK{+P5lPp-V#ifV{F; zzb!>ej^BCBf79#^{fQK9h+CPSV1EISLik&Ifb2=K8!jPP$ItBckwzQd@}8fovT

    -z70=?XnA!Srhe@{;z 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 delta 237727 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

  • 9vGN!{%=UrzCp5?{xQ{Ql_nzFQOeWaxVkbhRM8r2}N8i`E zNr7))gO^f9gN0$RQMHKcV21t2cxFK;O=;OOU34ol ztD&T1Zu@2CEU#=VR`*%r{8N02Z4I)~dDrV_HPE0iB&4e@Z?BF>PjDRoh$#TD}q20~Au>O{r z&?0JH?JeSzw(dl<3KwH4p9^9kq34U%Any0YYWV+O?DCWMiv8a2i_H@hd!G!LuI~*O z{U`aD-_&>5ezOeRQ4J3yCE;rUa$jx@1<|bpZ!c^7q^7P)8`2Ubf>M0=K zs$Aio#9vEnf1gj~|A;jFfiwn*TTTI^{MTOmyIT9-GvC}FZN{(B(mp%d6XNqfhG+wG zPHsgXy$xm;08~96@3LOOx&>Yo?Ty9YfHDwuV_Rl0j7fa)G+z`svg^@*xBC}v1T-8b zR&}AOfcDNmZ4@;h^0|h3u*$mP^`L8GI%W#6q>;qCo!7lG&_76>l^0iih(q+;Sn;C| z`Nx*o?UJ&{HQzd~;k6cCCC66v1Lzp48|Pc6IZ5Kj3pHtxFxy&=DMavm91BQ6Y|quv z9IUdbI7;*(iJvdjWI_WVVA_@XYCMk%09<1jDBYD>T<-dXn$)jd2XQ>xA>>(Ou(v)dI0RWaDH>U=pDZ)6Q1bhGAy-~L$x-`-)z<#} zajMkwViQTZBs2!Xk7bx%1bFRUh7cq;2k*T*mx=%{xK9kR~1niAWs@plev(Fnj(qb0Z?&mn69BM z04~?}95OTx7={xSp6q6ONDUM}vzy6~w%AaJ`>0Z|7=$WHdpaj|?}aMCUhf>eiYnHE zvaj6DSEwtOdz2OY?kyJ5_Mv?N_#(Di`xa}6lWw0CPEEPq$M@-ezhmD5O$|+XeL%6? zeCtiS*V&sK}uFaTt2j5;1OV&hARIKt$0BAJno1nt8Fa~<{KdL zBIQ~m-1OYFe-gCOc#_)goZEB`iZU04i&nMG8F5JO<@L0x`(ICLQtY#5D%^|#Y$Hc+ zzIEVIXdUl^P1O4gRS*MIK@8w>P*o6U+%f#dXjP>gGWiieIKtcb!6bZtVQkv^-qLsirQuN;Ccq{Nb*2_Y9C_87OK8PL$a>Y8?sn<{pJQw9JjM(M zZ%gx|i}nQq+=j{4^HO{Y&RZa&M(b}YMqEf)H&oKYT<14?I%Ul}k|OZ)D} z_Mp~SUVcpPx!dW#D+kYw61dX?Xf}~`RB=yPemIAQJZe`}P)m6Fw3}|AoTl9rSN|PD z=q#Is=zF6EY0Byc_>K~i+C*As|G5y6tBP}jTEo>vIVFMu2Zzas{?=U@VzpCMX%IAf zX;(5!d+lWKFF-S*kf_uL#+7k4?pNy}CfW9Py}AHua-Q=y#R0Hb=T~>3MwKzsiKbPF z5ubuQHOP6y!aJde;A75xh#}T?d2!d)Tb_&??zOQ$Hm!(`buyKka-^KRYDdnB_>GYK z+-m;`&|{n`#N)`}Djne8|HXb0L|eTjbp<~Pf&C72RrKFPibZ(0bLT1X$Itk&<{5n<4q|7#4k0KLYeN+&e5*B7 zt`#(#cf~bECgepU*A6Dnv?gi82-He=P(c^%~(yU@yqgg$9rypX7)Gb9O z*93R?Xa1VeGaF!KXH(&3Ciuc+Cg#Fhtr47h{a+_n$_?x3eEN=}&m7tN$j~KShyRqkR|)if>`{9<|9EC6o@t9vbS5oE z?OXnlluR9<9m@l>W2)v_6?~=;B14q%>F+G7TOt=rzT~Gyj`t~`Y*)61kf-4m-)Q$w z#kKIF_`{d{G<{Q_8tHZ3u(tNXamcSBHz|1p`k?JVEUN_XE{caCRdcRLHc z;>NFd!FIPZ+q>EN9i$5={CT8L6Iio;zOu#Q!c#BA@mH6B0Qyi$5%g&PjKen0Sk$mq zPv^GNv_KoHv;y}Ix+BxuDXw?_zEkveu_t^~XO~iYXm?si_Xc3JeJd{e8jR&Gam&~I zm7za%QI*0A7bu%9GEi;*Idz=$nwBHr0j0zBD;)N8-uOuKyjb~9J|#+)jt!`!{$Pez zHbyqujDIfO+ejoLica1&KMWRuwOTrZ&?yI#_{xFKw73v(xVA!=Zm(AZr_EB!f;`J! z2x`@6GhTpXrV7^fvZgmP88mzo*hUkZdFBV`FOl6)7~(3-%ili(g*A9c%Z`0u|u3pW4CMwqE1?aY=na-o`Z< zZt*+q{tg7&@MF9G!;?$?%_qp?zESo@vp_{!zQujwp>}^1C_Q;iASj&NdxO8j=%@FE zp<(mk%C7OLgD3WR{sUJFT*I3n{UGh<-)i?~EksW~*zOPcx!r%?)pmd3$)08iUIzg~ zFp%dJ2;U-(`jO8Lxdg92ztQer@nO4vh_l^)L45T`-WFHW7jh&*p1{5Mdb|I`wRZo* zN80`EC)0l7zhmMez5XuXxL#-So&VNAqE)MZ^_m zD)Esg9l!ANxcD*h-b{Rq>sYwcdr{KU?fy?cY4=CY78SSnv++z{m{)LR;Tra||39^z zd3;qxvd25&CMLND5J3baAqgO|1PD73FaimI0U-}mc&?zhfun*8g3eV3L=;(MxuT%p zxPYTF$R;2H$|fW%$|56b1leW=Vc-&h33K1K&N-EI?q}wo_nA4LPto%ndCn+b#5c>(T0Pl-ggAj^SfGPcRf3Gt3dv>xNwvmz4B_z(wgMxwtEo0&5r z(Q&gP(RdtfWTsjaN1~^&AG0SC-Mt_Z{c&m}+MRmGiRC72&Ml6Fq8sK%q8$M&%wnU-{bZpc*0z%nob0m5T4L-X+5}k(g`dcH>o~t9#rZZ`nG@0{d zB>ERR{}3H``!za@d@!9G%b>oagQIA_KT-L^v`F;jJv}4QsR#zrVD3H=h!Nj8FA|-* zg3d6?b~rdo0}n$DCr6^!lfo}BqGNRWPDZkhj&I)>{$~}ZO*(&A=cew%{7S@M;74O` zSS!VEN20sB^X4<)v`*dY8{LZ0K6q(#FTKp%op z#KGgm&f)0J{)eltpIlNu+Ulp=GhJF#bZVLt9-ruBw(aXw$WB}Q%yOP50Dk)}>xelz z^-$n1Qw}W*7biMbx0`nQvm<-B%x1*fCLbCfgU7_+k>HgF6P+XRp;X5#IuRCl^ti8^xXGCQbm*fv!*8q`GiW~FMWifB5EPOM1#UW`R&lmJ!HVeR8q1uq*#qex5rUspmiUDZtoM0uK%0?uz4;j$B(ggD^ zG#wIt0VEgwbSa}as5s;ak0kJW_Ca%;%9}7QhE_qse+7*@8sLxZJjX*%RL0*OOE4m9 zl;)Vmw3Cf}D%2Fpf~He9-KuNj?~aAM%6Ivd^I|GbTf^~)%Ke}Qkm6qOS3#`^jp)u* z;Zp$%m&V|W;B-qbxH_>18A~mh85% ztQH%~x-ZB0cQ*oAv)X}nwgG%+NTQ&6!Obk31#d@ZF}S9sCy15me*%_4NP8h^m}xav zKr2~`g0F@oj5cm*;Rf)sd8l4^ca8h?JAh?`s+Z}?ws3EFcMq7&{ENemF$SF~3|##% zaBV23#Jho9zrw>+k;(K7dDrXkx9~DD#cZB#BorS8OWr6xf`7LsDv-pp4bFijGOBOM z?j|Es+}_87O1zG#yc2SXlH$%_Suu*cf!&UW{GMkckdUiE6|mirL~vJ2p9B^MBT6dj zgu#*r+J?+?3kwg1m%LE@9q^%aOK>l|-3h@m?^+e_S5rz@cli39l(H>n`uawcRlvJ_eG^~)^^eRy zoiPVch`~3M(r{DX;QaUA{51!QgEo{hj~yw+KF7D~;+Nfg`3-*AlTzv}q#YTg4oD&- zt7&CX$h#A@1$2Y@`3>FAgvlvaE9AgS!rkucRo)kwY+#Dzs3l8I@i2HvWX1QuOI|1* ziGO!5SHVNzOv~U2c)P)|;D(l7>dO+-`tQJ}L3^0n49ca1H=WY>1NyMUn;O-JzQ91{ zUInYbH-KNoP^MzNUvbu9ui`)=fQgC4FVx{13jac`_EUVl+WGR3i4l}HJk_r-lQN!$ z=TT}WLwJ&~D}KetSJ63@y@srTW%B@74zsG4%_~Fpe-(^IAWP21vYZtEh+gJSaS2%V zfTJZ|QnvOZwHYkQInBpve+8qE<*GOhTubJ!j@9c%2<7&xM$KYyD{#7{7c6(r0zbpG zUIB8s6DsZ>g9m|S>4(%n1hNVg4+h&I9s-s`RQ)ipB%E3oH>)`#E5Vh+_T^OLv7Rt_qeFqPP`U zR+!@J;)uUJq8l)f+pHS&0Lz>y&Wpi)V{joj-Le-ft3&Je0ZUF*VB#-xu7d6u*bZ+2 z%Q8~^9bic^#bdy7o>zPu*mfY;ZofLXpJiV|b~3xdGhllPKEYUaEp_lIe1?TTgSSH} zST-%SmxmcUN6v!n_Cu0rva4wY$!L4dt^-RJt3DUrcGw+k=RiI<9a4M2_7qG3TM-MH zkqGP@7zMUN{505hFbr%*K*DQJ(G0MR@V@ss|4|MLdaFR+h6Urj4rTCufj`~$?(<7B z<;>&pa^G(pgKu~=Xg*>+K8kKWBpuKOcgA@QDjq=tjV%2-AJ6(DKemiqraJ+WmA5^- zD%EK??DU~H^HL^%;JzZx>}VV?PiH&k({_$owl2;@AZLA?8T&zdVgoHc?rK{jca|<23$5-l|b| zI(9p8*n|-_z{wCsT#d*)PZa*aAfBhqYmmQ18^MLt!L}y)=}SZOf`(>du#q0VHG{XP zMB#JRbQ(RZje~AfY=m+0A2{qXn$74&&iU8Wm=2CXCsZFtU9eB5jbk|cJ%j5Du1Py* zh{9glSsbE=1gktG_BC?Rt-;A$>Fj0Wg=tyWBip^AbiwK;;QAdRQ(W)t=T#D>dob1El zRC?c%DBMQ_ITFGHL=62ts0NNNGQv~H{+E2Ziuwz&?}yz`+WDGJ4u!9Q(>Zh4{21IW zqWrErYraC#oWQ{oRNPHRatL7=qwYX7;{Hq{;D$Ijj-vs{sw2CE{a8A(gb}5D>8&?s zy%*xG$m(u2=5ZWP!=^s+RZQ_5IZa~ z32BdIEK5eSjt0_cbSvc~I(mu`-NameLFd0BLZ8y`eRQq}z86vI1NCJZ8{xd3w28Af zX}=ZiuEBOVZ6759J3~}>kc#{1)vYwRO`PMX4Nl*s!Vw}O$@o6}XzUu((Fo2Tq=B7u zra2BCf`5&XrsC*x8kZlo?kAlv%yT?#QOcDB$wu1ruB7sJ+RZ*rpvMUHCHF0f-h-*) z%{0*6)rT);I`u-&P+2Z%Na#pLvN>PM4?dkwGS5Q#`2=Hd4?2nol>5@VfWh>KKUu{X zIg2Xh8{O_*S#cS-qowD=N7IRXMO5K@Ld-2eI%De}<`$t>9Ly0ui(Wm`n0w)$L?8oG zJQiHd!gFKvZ@GH>PiAUHVkjpmsUR1uOIgj5&B8B@`7Jmf61`#>O;t$ie+++xg+GbG zOTo7NLcubAZGcU%k^!mYhE(+o)P0VnD4#C5Y36Pr*N~Bm?oF?0%Vbjn>IYSAm28Gn zo`aIxCY#Qbsn8IpH@eUQnC~G`p2|vg-^xlJ4D47Lzk*(5T{<~tLZ@W&3iK2t{3J*& zO~_lYvvHWZA{*n^T~JXs-TyAz|M(}C zFLrl9k{fn}Z^d6>A^$9=SI?6zzri3xf=k1C(9werBKM3c5gJaNON29ndN1zr4!bS< z>|U@TT(1esy6)vEWBOQXKxWnhV}T3yk7k%0=MjvaBwf?=og6f@@GH9tM`gQd|t*7Lv<> zq~m!^Q+6=LblK=7Z!_wM(>vbdJVGhcBUtsOiab~n7c|59@y^3}9X8bdDh=CXjBed zgumoF6e8<%y<@)52%5vtZb){5Uj>%EK=H8{`;r*E zH>N)SSx2Q^)_u&yfBDW?nCK zb0bSX=rV8W>&*7!D{4OIjY;({p_3$2EN_csgHxOhu5ICV z;9eHqBYQ~}g3q^jb3F`Rjsl7wfUgPZMSb(=sGtjl7dlCY^%A@w1~0J&l!k%ikalog z2!ZS$Dv)>Dve_tZ2+p*yyxwkXVgB6Q9e~=mK_?wn+$si3LfiV#ZxG0tL@US^EN6(H z$TrE1dm%YXDDDa`<5ip!gXM5#>mLS7GOPU~@Ujp1IAlfvGAaZvF0k-uc$rge;4yev z6^efcZ#C#{OxIX?ZX1=_mz?Qcu4n#V2FMg>gMuY&is!+%xA61ua(_|%czBr-#S?u@ zQGV0A=^^gWj|I)+j|ag;9Y@c5l2$@$A{0@5GSG-918uT922fS}$2rs*a;%2f5OHOKq5mb;F zx3Q#^;ychwiYgv`nfKYF;sapW7Zul_gSLGpL8@ix8_I!7j+k1Z16Z~d#T{dCXK<#a z&jDW(gYEj)#pngw`cO{cg6#(S zgY6Ltw)HK*a^u!Vrhos!AiSfkJi)vkG-E#Y5?Xc_N$BY_gXQa`>l;Dy3O0+lkq_HJ zyXdDfjS>{OaOcufn}g=ouf32)c z^tvC^8yYy>dse%dQ-st_+vPPbyOpGlVyW94YO>8ccO8RoYT^Cxjqz`O+2HkdLeQI9 z#Z$nt`VJKZ%+JtKNUxGe(EJTL57jysG-c4wrBwKpubx7C&Iiq4{>*YAlzuU2T0;Y% z<4_sY(k5Outc6bB<(OKuC)auSOP{BhOPnw-UF2vwf`>GNauN@06KQNZ)SkJxuq|k2 z^7p}!lVt2Ko^!Va{#nn@+5TrJ^q*?z|JROtLc6LgU_Rv~Vqhqri$HQQTkdsYf#fW& zRNhVw4=sxSYxt3(_=ErQpOTGn9m2=EI<*onol7w-3c~T-oYl=nJwE!eyVIW-K5Fy> z5C1kjyJhE2Ewj_FZP#gNn>L;A9zHa^P3txtvRZe}YMs4ubvLIlE_^E2skyRGu2Wj2 zaY=@^|A>E0&*AXWqwbKT*8Sn(J)MTh=O1Kdp+AME_jDShES$_sK4@fqcy&)Ftz{k3 zQRtlx+%^2m8z<*eu#A@V@6TyCG0$n3#Fwm1RmYTtv-6w=$z=twyn{*#$MtY(hDYW( zPGX?9V*;JSzsqwnlQ+U|?Ch9@p8hk@kL=@^kvZWV=u>ik@>lKEF$PD3SYMVd6()l5^fB$Je^!?WVQohd_T^n1+>vfFRe7n4t zp~dozZ&^lIEFb4vYF%piF28539+qE>=YJTFe;JRp^LHRx-*{|hJhn9+FEbvy8IOI9 z$AKH~uB}-t+l&_<7>{2Xj|cL5#`drrGoGI`9?$5HF_n!9obzH@SYq;b#9q2F5bLsN z3E#+FOO_ik7G2XsG!j?5Wi^c6OZwk>ywv^s%=^&Ay05=groTic7uoftA4Fbuj1a%p ziYi|-hC$CWFHQ!YXBA9F>=IE=?4=#Of-1N^x}fTEHlZechaP0*v}_>#eNj8432|3L z8Tg0-k5L6TpeOW-kAEjc2l8XlC3*#!tO6S~;7R7!)|KgxF~0`0=MoJS{1lQU-%AxN zp$c+Yh99W~>u5*S&@?saiH0;}Z?JN1(JMy1^-(X{FT^0Kc#+B2--5ZVx0ug~z2vWE z6=*aBcQgMT`C&EQs9zkQJ^iRAVC>S}G`Xsnf0lY;IQ2v`YQQzrbK_Y0epLQ?=D)6s z`t_Fm%mQU7Q17DKzLh1_8Ua^a2 z`%uadM`QLC?NC4R)5)iVq*z9N8}-BtwnJxW$Oe+v)Fir>#%3_fFp^r9&-}Mk@%7)y z8t88jK|&@%Zqr*Ww)}US-fL-)e@U}W zmbm=B&9s*Kgc$<#zihcN9u?roZYk__(n{Fd`F8mmdo@1)nc%v)5hmp#5c+>xh$dMr1VLnT+@WUndH(plQO5I zi_sIar%e)*(k4#K9JT23Yip0mN>9(f=JJV+T$$P9@A_^+EuDWy}}r8SocozO7Yy>;3YITlJ{&b`zfnZyc;t zhWdw%@GVP2*8)p(_>5#?ycQ4m}(cc3`4FMBh#{9c<#0;BwYijfmWb~Iu%=lD|1rlW-fn}&O zK>9@H@2V$#iiyu)-b&@$P;?I|Z#VIY%pbdgdg#Jl6B!o{2)BtZWL}-+{LKGF%e9pGVXw;d7BoEiUoAhC zq1+@xn0b}n`fzj)sS0e&tMmzxULSvz!4VmNR~eF-SLsujSLrjDSLt2M>*u-p{;BU&_2nAAGp6od16t0+S43=2Zn&y633MQ5D#jSLqX~y{x}x2&w~e z{izJe%&Q7Am{;d}7xQzM%Cl%5^CL70xLKf(yodSQ$$Ob!LB5dr5#)W$tMauX7El%V zncqMKl`yX=2r#cIC}m!iAC$i4`hO=?P{uN-3PQ}Q3d)&R6@-~TK;>6Nd5piRKn#su z$EpG=^Qr%FSx&BlI?wWwH{!|4X=2Zn==C7v;3Yk~s`v=bVgXfwpZR;Jf)eIc1p($&14@}!+tAxuQJ$~pF$NRFt5@lnfO!_@6x?K|5aD3f;=V}d?voc#0O1$d4xCCpQ>1l zxNuLzn|OzbPd4$+zk9j<^Zp(%W|7y#7n%3~^BsPYld;Uihev49Emj$<&I^0HiBB}~ zDJDKcdD(s;RK;!;kUL)O3r)PA`9hkc0p@SC(duAcJN}ofDa-=OThpR@LV3H1Pc-o< z7xuXRzi>dfO?)Bq8>q!a%&Q#@nD{coqyN?NQyIc08LT5sdcwpfn)sARuaEyn)PmH= z0KD2Emx=e7c%O+cxqvt3U+@9}eO8p4croh2JrQr>9VR|`6ubUcEq1aDgQ$mG%&YVs z6Yn$eCDoqRpL!o3tPbey$1NE;36-08F*>>@66o!^jd?r0{kAjTQlo%_1y)=u*J~2< zYKKxyyvxLUOuXhZ36z-lpouSMUfm}U>Cru;e7y8E*Z(TuU>T09o?yP;b+U(2nBPR{ zoy@E9^P)V)Uj@8T0U>Un+h~Q%tMq;oU&_3?0}{HB*XLiwg#z%lG0|OjIjsUa^PR~% zn4d~Ll*Ifr)pPL1{8vZ7u2hhdW$31sAM>)HoB{yq6{|Xz2xo8FCp(>zL0zp^GnI6Xe_Xt z0#4>%B=2H=6?r%Ff^KAbnOBpshn}uO>5d5KJ8Gyro;#6EUClfy*4roSH6G?Kqx6N$yJ@l)86N$wmY-@tfMrm=jQJ_lr6J~3`U(?on;6k^ z`uM9136X&apTxYMKD|m|UX_z!;@ubU#{4V1KtP{=eiL76;zK6B!o=GqvFm@;f`myC zT@=|9CO*}~yG*>N+SB?|75l0Kdi!gJro_YtO?SL02YKkVW*R+FbN#OZb{VK%mdYoZ_!JYLVdC9U9^eoWa0xRzRbjjO}tghs_u%Y7TZmH zqKQv2@fjxGO}_g2Uv+h%Nd~`(FE#NY6JHVSwd&<(%f4_=B$)Ul6Q640T^ID2f1V2l zgz%a85)&U}{*@|YL`6CC(^|>ToJ0;9tj~Y7{C>RxU+Tr5T@T<-c^mm8=C|A|zjT(& ze8y=byKtKLya;csKb6575zvcJU#%!I@d4&-m&#Qma#;gL;Oo=)voh~mLD&B_7D(zP zGsH6=Ua5ai7d^zhm?A4oVBS4bdI$6VYh?L}x$OEsOo1ep!FH|8kj%XMdg)V`wR?q+*TIkVc!0rtk}!E?;d;&8-In&YtN0B z0UryLQHCPs`;C+7{meg4zJz%+4w4cUK&GzG0(ggzZKDuzQF~65SaExdE zX}Wc5XTIKf*|iCVNByeeX*7EsEW^7oa)%O`KS0YYiTMUpK{E5Z+RAcLBE3HT|E3nC zMh4(#)s`8Y%j~esO%>o}$1s>)bQOmr{uf1IEXd(08 zP{lsx&;KfSv}g*u{-4x97UX9cw$l)lFh8KROdnu=4>h2a`GxdN>0q@t+AlPG&xVdNf7)n(O})Y89{y zH}{nlIGI05$B7K)8&Hc~%#Y|K%gKxK82>B^xT69>Tt_YQFz=>i>195jCRZWz&ro_F z^L~BR=<~0L1+Jx8?Pq=)Ewd8lKcuk?Fn<#bWhwJ_Q;!DWjrp&RfOpd5Dq|VMRkG_s z%zs2jv~uSA(in%C-%AassPVM^ev`9Z+@eN zzp*rCVp{YnXxL6pMl17aw2p1eFaJSKu6X8OS64(4-dKO$rm}*Bh=5+iowSY}%=>8) zB{J`(F-~HBH}ym^^X)3-@kgYvzwi^{+a!a> z#Cw@vK+Cw0`4H`>PkB222^A<(0ofq&Fiko?^GDPb5%XJVXadZSq)A%Jyk@VEH3eDV z6je~hd;%@A5c5sw98u2vYcynG=9kM=pot0==t7rDVtVu{m`9V#%6uwKQXBKDX_>_{ zzn6N*Zg}*+T7GtFK?2J#nTEi@e3Q;{)g&^%nOdC0{6looN{;mU_z$N*N@M{3cRG}& zGG9V1b24xFT@Gai^Doiyz{UJH)N9PYJQnzcW~rO``c#33`9pNE>Sg{xx&SF;z8{^; zeAC(WzX}wYWbm8#5)&U_{t%sON~=Asza@0c4^{_cS6)q%v5fglYFUW+T=M12_g3!< znEyqiJ*r@VBy~kJBYG9Yt8)SKJ806`n7@N6j%WS=rPu5%ForTDFyECbb}+x0CRZZ! z@6#krV!kUKZ<3|2x&Hr>P7*0B!|gN#sm$lmvUD;(jn0Z0%wITLgs(ehd`fyJ{A~6T~x&U)9PHn{1h6p66UAVaV5a~ zi&Rc2yfOdP5pV|0-XP0h`9-dVGUofL_XW(qLl+w5%=e>f$#9LA>u)8!b*iWd7&Y#r zNhIh#p4!oA)TLJD2h*ytF~5pV$MMX!>rB`Gb`}^zCzS-|SI`(cnBPnlBr<=V=1>yz zX*B1OH5PDF#VO3Mp^8(PKd#ON%nzeglfnE7I?%buS6}}>MtkI88J<+v`OK^9hC=37 z(WEP7{(QE)Zy1dBno$3CvphOb?Zu!^M4oIrKc>&$G5$TLKHH9dR%} zW{@l>k@@lDlb9buKACw{PReX{u|JM7xLAg_sKs99$5P7*nOEt3%%{@pqG~S>HbU)@ zzdE3=5Fzqthf0`NEekNO$|+@DH7v+{ZH>mboCQ8ll3g8UzBZ+|&WRrTy_DX@yecQ2 zd9@>&odxDlK?%$cq@Hjve>wR?<{jjdn7^KUvh+0<`|YS@PL@HnAcJ|8-p#z~2@mtC z9B-7z_|u+iOQ*wt~%&P;AhxuCiZ>izW%lui|(ZU)p*WVM= zrM{YgQNSZjWDA1KH>3*6m_K&2tT@Dc6Dq%)`900(VqeUS?$Sr;I?BO(Z~}g!41bBt z`yZ4(iTOWI7|BH{^CQnl@6uSH4c(gdFt0NBn174rN(uA3dr;3Y-(?V82$HY9*ngV# zNIV|h6RP5P6Q9g{n0hFM`J|z;hg{Jf<3CXb@}dJGFkE^!^PzG%#)Zs>^YQz7`15i9 zzVt=Rr+lb;)bD43$@K4`OPN;{hnV*-mKBsU?=mXDZ*y_KQ>L%by*~fd@>3Pqo`@a- z`x2QUo_Q}7WM|&DOQug?K0w|P;f?iYdsb#hj0osmX(gY;{7W>6QkhqC$i=)`O&;bk zd5!voj|Fznk*kFHE_9f05IzW{$znk9n1WkN7^XMv}zQ0`_>5@&n)5LoX zkNVZ}v(tg7kY#X=mIopq^D}-lS|Cc8zpf=6dQE(Jq}Rv4Hx(qFitY*J<4wH7#3wUf zbeY^CC-eH`HRfO51p<28yv*;WvtkkREt|^<0w%tUdDSrMJa+wWe_l>T+q~$W@X>OL zXFi_N+nH}eK7skrZdtyg+8gc1mwjk`CsqgK*owU}LlX1BkX(ML%==c!^iJm8FG`=m zd>d`047gZeITe)0yzM2K-p#z7%JDEC-X_y~ng5w4saC`S>cA3U-a)IQlzCf*Tpi`i z2Pl1*c?YGhkiO>ne|udyiEK|tkNxS}1dJv*=vPCF zm{$e)nO8ef!hC?1ae(>YUQKp&DGT^1Ly&n@P#N>@Q~D6|>PS~%;%(Z3=&@885=?xO ziBC20E)(w|Uw!?rD)zAq>WxGR^Xe)&$oxI6rZ9yMFjL6P`<>(2TgoA^L^XqUaW@a^f%SHQ~5$KqQ-Zr-{!q@m>>OWO($y zT7GIz111^DOnlhHTk|j66ZS~2kH4xoF)|S0Q%rn@i7#Y+`arpAe9W)Bt&u$c7qP%K zx62HE=G8h5F|R%=Dra6DaKz&1p4dlkc;Xkc>;KJn$x9_W%ivYN!OOgz{^7QR`7$ae zk$FG)q-sy=?<)$VR0s6MC&Y*3Q<)EtcQPL&?_z!{`8?)#YZP#^Kn1nT!@OJlhzIle zR8A4|#pM0W?;@|2u)szN1eiZWKFIvL7XU;nGF&NIp2HSt9zzKr=Gy_E|^ zdrb_W??Y8Y2Si|sJPFyJkM0TYZ0X~fce|yxGoM0lIun@x+_)=-@lR!e@D5o}9`mlZ zrFSzg$a|Rg?UU)f%&W6vk?!^Rr)QvA`w@Q_g=9n_@` zmf_e#vVuhBHwNU8B{BaSRgld57>`V!Qsd?Nd-D+)NUaGN>#tv&tk}u?tE;5XU_NK4 z^e*P-Q8{_c$5`pd{@pB4P8E2Vf5s{+@G}3%YqAdunQv8BruQ+whvra`#sY00mKpra zkD-D}m_I&5rVlXx2Bj}$zEeG!K1ja$`hOV(%2=v9!{P-d_)Z+%I68}o-LeLVAdbX>7Be|san9L&E27T90}@JqAIw`wda zPGo)#`6TAo)7dYX`D=#Ba#D1!&wsW2K7B=6}`yFR=LYF@Nb=t6cv@EYRsG znZeKeN7UjH<^#q*+`v^d^RLsnpp^L^)xSLPY0;Ce`!DhsUdA#UJ}G^O`9HSFu`Fl4 z2mSkuF!S!2@|v+idD(v4Y@&abAXY~2(2zE=0xR<~XcF0&|EN%=k7wS!TK1@&d2KsY zkiY_uQw0v@7d$U3NM!!HHL`*v<~y~O>64j%T9%I=`(%MGR6#29Pf|fn<`>eAW-$K? zmE&T5D(z^V;nDwczlAtT6}VZ30;<5n{F!#LWnSjT4wt@=`G?y}?~C;M_`62PKv84> zej8=*GasNMU3{YWWPzSVGD8{j-H<{53o$==we;o8-$Uuc z%)hZlrmtAZuK%Y}2C*u76@0T+X0S59oYLEvzhu2kAJ6;_N^h_BwEk}1C^IBf2aJY_ zA1H%^`Kg;_`b6gIcaT1b`PIeJCo}KRC_@Sh?0-vUNM-(hO7CR8(K|AI2J_P?y^HzV zUY6;#JQi3$8Qjc2`Krv|VctjSz07Z=IaJ8}MoRCKzUKP>51K?pEW=L9;Ag(in{tOr znBPz71I&+IC)1Zkd5r%F$`FhS2(gqVQ5o}9ls?4#$25n^nQzijRvcztY=PJ3Uj++X zP8q~Y(W{_$i7d#64hBKnJ4a8ZX!1cFK@a6EN1_>#xZQQkg$V>7C3UdR?Z^V7_xF>0QjXT%*ZA9t$`r zgPZvW=s@IQ{#i=zW&SC@EWeQXZIs@pvA{bUWQHQ;J)&A;>b^PY0qh=BH8m5c6~2mKB#XzlPF>qrE2HdRJzshz^KvDT8=9dKGxO z%Xh(6=Kbli92@h&d^x@2nLpl5F9-9_&H_#8A9f}%pQ6dDUI+85JIjg_nIFHIu85fb zAxrvX-RtvTEx$X)%Ysr^hU3(NROZKADbqWdpGoCpFkgGItjQJOjrF&vi!3NFBA~Co zHXiBS%)dwfZ*C9s^}5OQUgo<{IfcxRSxwjfJ{I_M4>)A{ z0P`(rh)Z8)*Z<2XLy%<%kuPKZ@}9DS5cA{7movYPd{}vT{KGpJx_MNg0($@BucWuE zK)e#Y3J%f#^4iM$nppD_iz$$k!p;I`lVyek=6ldvGY9k6_Lb=qnIG6s z`XuJt^*6jGl3Acs21E+;0}^FHsmymAf zTXNmInNPT0ruQ&EnY@?z?c@t1y*~a^l4L=?$N;?KM(K-~fBk;x{mcjGeSZn_88^%H z0p>#w()E8S3pi;K1(|<>d>Qjz>Y)(xse@(3<;?fIL;8wW*!6$gf60J&HF~zY@08xk z{E@q)w=uu-9_izmxBKN#+N(XSzYR326RHFH;={RtZpk>9zh;H3Ad&g6==NO_^9|?A zS(eQFZ5rLbOJRZWQ)Grz=0C`l-pTwfdXt*L{4A&3Q5W;u$ZL5l&@D}7a5F#P6m$wKoQGuHF-btR=3=t66QB#$$QBG<`v#E^v@9E?bV!k8Y=Pzge4E0$SXma_P@0~9zC}Dm$r4KN_o6?su|1hNwlCQr0 zpRia~P{uN-9SSl3E2S@I{ywT8%={BeWcd}*UK1*VD2iSMt)G(_tjym{71)?xP3hyA zSLyA{UtOS=kNKCt0xuZ>oPL>qkSb1Oz8~FkO=4c9PiDR@ooiBbug`zA{C-&`Tb#-= zOegPTe!qSI!CwaRNp#Y3F<*xokQd>N^|x}lEXW-Z&rvSg70i#Jk6^^>G}&eQg}8?TRuz!@BW6*T+L-q~AuEVy zenO7iAv^N}rc0l|yw)sR1{^G~nr@RPGQXHUj!$C#a{352nfY%ieG2oR$@KW2eY3zI zIvqQizlAEuV17Hb%*Fgm^x01y^Ao9jx8c$Mvif$0p?H9j+Qdtp^q#-$o!`-Tjlw`j0MKM zE?XR8-bUy9a^~C61xuLuHS}3<1@rz@a`}nX?D{|Cle5~oI{I9&V5Zz58}nhho{wk# z0NrA-GoO(s%SovAwEhM>Dg%z{fLwng$R{#CmwXcQuai$^zPUQ)GyjQ38B$r`6nQ7} z@w4QPW-xypc^C87P{n!7kEHaPn+5ull858NQjFOt6I z`ahBG;rLmG0A{KFSHgU7y__on=7&Em)0Z+|pLQr1kudOPzT@(J)p z{W6&l#a-or!oe~eERiV^nRhRgJDSA&G`cyJ%zStHNw$<4FW2AYbQ3DICSZ(t%yL<= zlX+XA^cl>1ebT#_-%E$;Jm%w`r|Vxg3s~Ni1$mfnOy897GH+cf(-$&dcbW7)=8tTY zzDQ$%ZB&7u`9H~*FhAsKIb;FmPre{4C}rOAmh?gL)z|;e(K0Jz8Fu%O6@-|#T`qk& z^Y*oJPK22sPvuucdrdf((#c>ATLtrJ6)m`wS|8&`aB<2^;d%tAn=aNs+y*~fthzN0*0;w#+8uCu&htf4;2J>m; zUCg`aUA8a68|yDPPj;z4BA~Co1p4Y!3G*lN=oSm}0V=1IdHaj9{2=p%nf2uQFJpn@ zURgni`LpE9ncp{GrVleehb}%Vn71yJ>BXDu{{JcZhNAV&=*gJ#i_BnSzRP3M$20%w zZ0YUHe@Q+;dD(s;-rONGI8;FIfBo;2^oh*BLOYto{Gd59eKPaUkWXP=bDfqMQd!{M z-=%jl@7g2BGK2Y*G$&ll+vtnTdCa?Z8|7%i%>sfd@G$RrTJDgS`6hozU&#Dy>QNu_ z4mz$B86N$wmS6a3*|JiW;XC>z*&y=)IuMmHpYoI}C&avIaOx%qP*%6f*DE@?=3i7C1|{VvCp`LT|PF%)9C2RKmRLO<8_``2d{@ zv{DujG{!;ZlNQT@%9uY&UtA0^U+*fqK45-1T~CCiuetv3N*O9xh6m{Lez7ikwvVIq zR^}UZkrmsRUuS&YFGPHl$M|PZ276RMh;`%>m_I|_!TcjsK_c__)3078F+W`Q`us~~ zfsu5dE`|9_>bg|sgLIF`$-JBH5oK_{LJp-1-kAUD2spHt?7BRb;a~LAtZwGl_Lb>9 z%nutVy_fkh>i0iuyj*{m(47unO~6=w3&|HT-$C6mVg74%$AtOzw4(v$NA=a@o|dw} zS;`P(ekS!q8S~B5505gRNWPr;Zq!3zjRlgaiz=8`zhEl-(W_tt{W7wZ`9Abk%f`Hy zei<#EeD(Fe=UUklc9vl~Rgl2^B>HZ+gLyysMCJ!ki<6?gCcdH!$j39d0~S2=VF0bR8SuCKKc<^H}`Yo^}mPt@Bq3?&b+;mOkb#beg3QE z_cqZzz2U^C_=LABgbA`m?!W9NnUJ6R*_n6Jg@c3pFJ#4u%%}LJPhvi_Pc|%h zJ-hyQ&5{{XSO)t`GD9l!VvJ1hWIjwjgLxl$m-4dxLbz!N@>IY${s}Q~zpTK`e9|nr zLmuXxV`ch6=6|A}a`iE<^`;8}KMUC6WCbP6+sOx*7jxuErIh(#eOXSBc~@h@Yod$= z+o0wn`$Xw0n0H?-z1X17IrP6;e!=~+1y&GknXxuxB| z|4tT2q=GV-57O`bxtO=pj^;5RYAhSzVctjo3dXyEUH`Y#|4AhN3VDV+>HW<6X||U# zpYo%RQaL^drm=7M4EsJOVPV#ou-|Y25-!NH5={pW;C8{&OIHt5}5Z=dI$3X@`*KGuD>t^l4=6R`U}!^ zKr-`hoRCA4!u>0%Okc$OE%c$5pLs#YgA(%9*Z(~!5MUV&QP-6+KlG@q zILN&Av-D-mCwwn`DB5eH2mO=F^5}qAIZ)2hF!OKH0jh%el%6uZ*c`nI;^|I`m3jHA zc$j}S7U)75;+cPhyq)ByMCd`h<5AqVq|Q>0JSy*~fd@^kH#fh3l}M)v`enYX_u z)2A@+r~3h^%nQ0lTI=nT95chJ>sm}Lm7t6n-F zsUh=v<*Y7cKJYQE4(9#zMn%2Bp@08?>wo%%q7cgvpp#9xi4SvMSH5#9qaT~le=Q_N zA4~cRMfh59^7xA& z9wPYve?9%eG2iN<*gq5>%uwib#KnOlmy(jl1e-zrLqUA_66J zyIuFqB77jiw~O#Lx?!oOZy(`9^ln%8_6T1Q;X73Kzn*~JspuIxMr5!?_)Zbt7U3_8 z@CgzA@(AyZ@SVvU^%q41u87DGPgk+}4kbi*cZ9z(!uulpRS~`@!gq=ACF=TLub^v0 zAP|wETZB)hD<3_5_XzKd@I4~DE5bV>d`Y!O`+G(N%BllG^osDI2;V!xha-HS2=Au@ zn!W<$Pm{@DNrX36nHv8=8b4StdmI z<`L-~5xzx)PmJ)F)*S!vHasE_ACVzB!ncg@DG|O^ginp|ts}fM!naXgAODPqKwA~S z))nC+4>)-dzC%QMcZBa7;XM()UxfF<57l4mW7a<+P#BRRF~T=Xj1|uhh!wY99V^0M z1ztz~W#^=gnqVJ^hn%dmQRxHneFvQO7iCVAcis2%U0?KD5I<~RzpVXbEuUU{?%lQz?*IMt9>))#w%XvR z|5aXPqcztx{_&Qg+1KPd58CaOL-L)69{%&h`hib{E_F4im;J(1kHsGy{mcE`_StKH zHTCj*_rWAv;j9jK-*bFcvj+z5tCvuDaCLs*pxt&~_Dzf0e>3R4KU-Sn4V!anc7FJv zJ>PLCDc^m_eoOmHx?lT3hvW{Awb=N}7xx5S8Y>ZgNI`tmw5U zR-9TAE50Q*uHV!q3>(mchi8O_h+YPWe>ge<~we@YiReZ-0YlO z*U+5I%!#8i(#B2B9*RC2IWfIkUShuy{rYrsjp{aa+~m=jQ?t8`ot!&#;<%AL^ep-| zJ8P8jd}zUhu9jB$?}T>rFkX-9(Km6_n8YzxkLulbWbeLxdtQBY|6cw3_3A&`(LXJ{ zcb~LA>Ae#B^c&r;fBq%EH7$6+Vrg6_5Hr9Y*m(H6QI`M79_Igb=F)JJa#M$P=Hx`@5DK{YQ3ME;e()XSL9&J$|_KhJV_dJuoO)X<5^HjLOVP zH%4d7q?{g;GDqiUeLq9H=u)zCMjM5=(sDAoXJw0v99y7)LOA`2#UvvkbQJ<=q9!w;kXck|>Po2BK(7RUBq{E(e%W$BRrOZi8){lE1U*45

    Ua+#WrES5w2A0_T1;5_E@rK{tv1tE`Bi`Ru8unpo z*0`Jtw4uX)JPPV7y=g&F3rmxPf4WHXpFi2R;N2#cxct9Pbt_oa8H2j`bd!so9t9`c zSvp;H5y$IuXX1^+X#T5buGrY_53A*3GCzN^Pky7bJuf0l!T$DW*|@W7V*YW@=$+ZT z0RJZou`xeWn|IG?*-PK|*&WW+~wfys4GUjJ&V~ef$*4mcQmK*=+ zEM=zv9M;q_DLzrh@@-5()4G;*|C46od>>yhImQzEzbQTQhj#gn-Pz{{pUxBCBIp&{Ks_`+|tX^zPPG|rNuw5Ab(oLP5C$d+SI1@QN3{}c(^6{{CrEx zrv;yNw8R(mZEYD|{7q|1XG=kT152CYJK9?EEc!`WAG?C}jV!JH@qX%O+b0yjgrzM@!!r{hZaN_=C$V<1gwIg3FnLEln+r^tsf4icBAy zHfox&E{eBxww$#TXIyD%_s=`2pjB6l|HoZ$u%6r1GX8&hxe&3MCypCsTr5n%dojF~ zvtMh^PMIoIfZ65dDaTfCvp(zjW;6ODL}(c`A*udnT(bu(@v+PhtE z*F{cR_5^*Kl{<2pzRga{HY&I?bFz^!J7e4=-H%L@J<|1>?gRAK)6%G?vL_i0u;Wkn z*`s9sjI4B{p_9@k80F+la~a#*frla`Y<(Vmq%IcMCYbi4j`C2N5F2K|hcrr(~hk4YOhF*hr{t37RG zW)|*KjICGvbYIKC8@28OCl-NHa3g*rW-NFCya$ef z27}_n^WZZ4?90PoCHNY&yERUvgEin37=WJ&+6DdsH{TW~rUO5??)Esb`*!>j?~eE# zm~r59Fap2*vIN-iQ!jboH*ht6-en=!1^xo}<7ZtCgUpmT@d`KyF1b5S+zK8A8^Mpj zfuDJq3f=^#!BzLhiATVB@W6d>;s|ivuf>Tlelh2v2jaw0&G7NkT+Tj;krhrdC^P%W9un6o2WALjhZ-Og^BR%*xXgVTJ_&_UXoJavh z8Xhjeuc1r_JAf5GXmUT;22O+7qvFK3p!sMN4(j2D%tnH(pwk%aICvJc8XG6lKmiaL zaiSlX2v&hI&}baW1y6$`pe23=>@J`^hljl&WrCD)eI9Lo$Wa;Cdjn;!*;4jcS z2d}{{&?py^222Nj@B?TwB~IJ}JYX9*4X&CRC!F9puouMSp)o)Mo54?@*R(j10ak$T zLD%W%$>|vXLOl5dG@lVC27}q482k+S%#0J`!Fq5Ew0#t<0ZTy;w4H_5;3=>j{0_RH z%rsB{z5&hLapHF123x=h&~bL0NCnSmc=!N_IdS4zkOww_FzEUi_86=NCqSRMXf^l* zoC3Wcj}zHoBRBlEJfJ6X@|e_85E$PJ@=KG4oqE-^8+{nuGxU41|A1{ zLDP+K;x^y~o54A7(d(h{r!kOm5X zcnf<8e4zDK%meT`_ypYcHYx=>!8y?39qc670j_!%Jp*#U^B@GS*oM}Ea?ox&8Vip&ZehxuSV*azQWG5#&TzytxK zfCsz@J_mn*HeaFw@HU9~3deLX49o%NK>9um0aypV2lc+j90BQI4LA%YmElAMioqdZ z*&io5gTcVHALG9SPxgRL2hbDX3s3>NAH<;(JO#Fc-@&zqaD4!Zz&D`Qzi~VO!@(|a z0$lP9PQPFx*a&_EjlRVp8Kizz0VhE3@6fH_G`Q+8x(_@D z-Uh#d+m6JE$>2wja}+ZjguoTw=rN+tOPs3IncoZ4<-OF*bEK;TZ~oo1rGx+*bL4Atye9p zco574E5S~14)m%G4;Fy;!CBC*j#boBHYf%^gC<3cdxk z8^8k>copmcmWD_V1_Ku;1wVr(jj%&tXd`^ZaW zfZ3oF{0y30f`Y(MFdMuI_JA;G-UQ`<5nwKO9ee^#f%v9YaUB?`;o(WJ27CsFHbaX+ zF*pP)%~3HJ3|wFd*b2S{wOd%lmEca04GO`#;4p~06dq)QLhvp)4C3OgqAM5)o&?&v zcsLB=S|R}M22;Q?5CGqUdabOY2e=Q+1M9$-;4J9T8XhbI0q{Mj*9IP>gLz;b_!682 zJ=!8YmRoOX zNgy4}1M9$-;4Em@8LIo#4gpIKWB^0KZ15`B1Hz!W1G5^80CU0X;1h5P#P`IGf|1}!um*ev&Vbgv&~so4 zSOyM*xZW85uD#LK;7PCsdd)_srxq=R{29rzNQ1?~ExOTjd-0=y5(LBoFNQt%*{ z3El_gpkaS_@F188R)U@2r~Vj!TOtmbU;^-h&ENp20+(Hl$ptj98Jq*X2B07?6RZR~ z!B3#WH7Ey60A8>Y`~+;*Vv>S~ffsBB2Y~H5q}T8;0eHb?a1Qjk9u5Oa%FTmkL?kAUaEUhpeuajR8a1Dv;F z{2#}Y)!DzFQj0GIsBD*AyTzztpjyTLD@*_~G0l(LH9 z;4x4HJ_fb!vWnY5CRhyK0^fjIDOQmH{spo?0eA;|2V%9mt)dG^0l8o)*ba_@R`xRon>P0739OXm!6;3JCRiU^I9NtOZ|y z3ea{0(u1*JK3EU-fl6R^Vn@LYr-nVnlMlgh&^XO1`hbVPEbtO21wVr(BXLv&lfWWS z3VsGnM!|!jU^aLa>;YlWd^C=hU<8;8UI(9mQy@MaM@tP4Bf*nk4fqV40j$vQ=~g z_kgKj`DBd$=XhexL;&0arh?^Q2lxThcVTUV`@wYZBKQFO2pTgHkMf_pVQ?jxu=7aTMAE*TOIoMG!1Ni1( z{P*EWC9pq+02mLR1)IQe(0DFd03HI*f=ysQI1f5Kj`s;*5?BO^!Ox({6WCEO6wC&% zf;}J%nm>t#fe~QtlW4{3c=8E21>!welVBux61)ozgSe;Q!AS5VSOY!-XF%(DSgIf$ z%meGdm*6aD_cWFs$N&q#2JkgF2Rh8x&;NLs0K8x`H~^}^Wec$Mz+{jQO2A3b^ch^$ zf?;3|cn$0Yzk(Le;M2ZTZM z7cjBF2rw7C4n6^=K>P~KA21R;3D$tmz!?n>tzX0h0_k8LSO>lYXF)q3CJ@K~3%~|Y z4jQh+bs~5W%mgdJPVf`3t-|#kco=BlW$+O=37Woy`2&W5IY4_24|~C{pvB9WK)?we z2djbpJFurg%U3Xgz$oAWZ-URkAE3>vm_J|)cpCV@SKv?3{x!@WFb+HeHi9zn7wA}o z@xQeQ$7rw+>;iv*j<4gg3`_(I!4_~3h}AfDgWEwSSPb3*-+)?g;Lr{J1+qW^cn5q3 zV%K2)fE17mmVzMoeT{~zoi}mO0Y-zTz*_JHr~qx(VgiA&U_Mw6_JK-ZUxx_<#)D_U zCa@oz2c7(wKVTAA1d72SU|Em(0|o;ZSfb%!EBF@F-hlV>;7*VY3c$D^Bs?9xxRw2RpW6 z{C~ic`fuYL5AFxk!HeJn@FQsS4h}RR6+8-7fnDGPxa3_NXuuHQ2CsnK;1|$r8|Def z1dG9L@C#_R9Uf$Y#oICdZ{f)|pjH4A2>c6VfdcRj_zuMGzytzg!F;eD>;sj+{vPH5 z7!RHWo4|f>9&~yi^8ic&i$F0r1S}t59)Q6b9$a7v*b2S{wLio>0C$3HPzc@y<)G)jak!FZSf-T()|)q7Al zSPc$0SPQ-ZjlMvu!DL|h5*fjeFP}VIXaO;xR`KD&+QU6s zYR@`iai0tGI${s?lCdmDO`46}>WF`WD))4cI^yZxb;NRPFTbUZ$OFd)84OfUE_%BK zg>1Q{;CzQVy|g-SS5|#hSy^@9i^{6v`sb<+o~o?cTKim8>;08gLv~eG{k65S>dqaN zRo;D-Rg=E2tjalGS+)O6W!1fKd3gSEu(Imem~&Omj}b$B(22^b0P+V>=12Oryt3+` z^;}ib!OE&u-&I!C+g@2U8s#3uo#lqxE48Yge^yrY0ayLIvg!ybccaikRQ3QW$Vb6r zk$5x;Z*iou%7)C}AfpvKeG3YF1C<{{Maigi7}CGAyRz!bpDL?H;Q2A6JB%F-q4IIa zAH>caNBI-+d^;NY80s0nt9E78q1b|6o$Ac$-(^A_aelny8a!_f*380da1{Isz6Qze zIwBQ(@>m^VpIfKk+gXj97o6)9OVtWqThSp8#<3PC2^0&Ks>KbPu&e=4;D z^ai=$2&jK^tk`o?EbhI;ia`ToMZqAHe?zQDz9Ci<>u!t{t?}X|Y!@fRiWk6(c>OfC z*J3*f+Xo`Zf}4i4%TdrQJlA8+yjZasJOQ2s8DIn00hWOvmd=ycCsz7T_HuYDmvtN30*7*8=5u?nCTbZ1v|>&VT1cG8CJt}kLm0NYt$GbqRF=RU(+!1E`F<9Pihp6lCh$VKMSeOu&v zQ$tYCyoVC&0li>7^D)}$l|;t%*Lp>IfnNYUJ%6o#vuw#-aUuk60zZQuDfkA$J@||i zh6e)0KOUD|(Dxn9V`|6-?E<)~Zxdkg;Cf`6f&Jcgc$ z$Mf;%ozygZ@drFN1fL}1brH56yw;z$!?v4-2OGE)t(lKtGCkji?OP-9Jty!4cpR?> zW7`O{0ejpS!`Td=$P4+duJo@>z`k9DMG*44-M=j?cP(2j>yE60iS+ABNYD<3su9 zkl}H>ehu7%0=)RV`&uvzc#uB+FMJ~e&%XzE$(JRDf?gqj25nl`9&^jMas! zoqQBK^dMrNP-UueeOno^vyuKxZMwwk^5ADDd1TLfJyD^JfX+8v7!K(uAm(L@xM!pdDpx~GA{B^Jb%marJ_tJAcuD^a1+p*YA0wci& z#J6CZP0#BgJ_IfeJOFM4OA$+^=X$(9o?j<0{=M*|9hiaym(laxSf{P=T>n~13lIYy zMqK|-f3C-~@H!9ZJE8B)LNJS-zlyEi3wo=Ufn7lF5&d~XE8Y$_!u##IcyF!6;;kv3 zJYEm?L*j5N0nam#U?lumI160+`nXlm05{seXt;_x7*8-4Y)0Bm*w#lEH$-d&(sycz zamT#*0q$0mHyO_pkXQfeix*{`Mm!05+My0@FM{VF7eS^XK{~b_km)M~d*k_0IDKrV zgK2pE{~7H+7T5$ks2}lqV~4)BT|EEyd>DVd(Eod)e^#j~Lv8=j&T6Q9*MDeLWPulY zuAJKblLo1@+CSRM|FDDq4`pc8W2*1bX3XA&jsFjCcLEnx^*{dq+?lx-W|$d9!5wi# zTtUS&a}O02cTy_KB~(l;Q!FbjZE#0LMMbZ>s8nQTWSXW}Xj*7yWT`h zUeQK{-unE#%)czP<6p+#-{3#R6}bxK{QipB|NpcAe^vPB@&7c<|0?JDm$j>CR4&j6 z4#Jyt{^#Yto6c3y;D1-CqRzir(ME;;R-f@M_Zpr1m$kaT!GDS?auv!M-yi?ef_TQW z|Nm*^|8F_heQPe4=r9+ZZCs_I<&}!kvn4kuN-X7B%7y_vZc}Ov{CgY_&odvT4y84v z3q|Joc)fqTj4O((BKSYGV&-xI9xQT!Wcv^1$+_GkLf+uoPw7vYOj$Y4^Yw#9*q_pg zvY0ZK@;jx*d~PD%;jD#RuJ0@9D;wKJ%7DdH??cs$H$GFlsIz4L~6zM8Te0sFgvN68lboo5mv8pSV&k{n5}^l z78Yc!6K3}hY}6>McCG3)Dmnea!h-9Dg*qxz;eYWjG%U>5Y*E8BV_29^SXfm-#dn<(#7c2icIzf*n?qZG4PXfcbrK4vk`*DP+SX7R3{S=6_g#a%Pwa13>V z4QBBK<7#MTan5QM#U``()W_Ua%##5Pbf;p3zgbje;(1iqYB!663`bR?5rHPL*Pmz>K@=xN#@|o&loVV~*+w}?*{oO;lcH8r>L~mXhh6ni z6y*tJXOsTwJ%_tnl6ub9oz0p#r-r-#Z`RB*l|;C+ozwZcCEU`uQ6v7}**jiss=4T^ zYxu|)`DkyvbL_AeM~##p@;y67UV)qwAa?m@{$wt1Q9zXPpyY9Un1iz{qF2j${tQ#>#%N zCOi4-`xmK@F!fe>xr;K5BLAx2V32Gm9J82#;bAZpQr-AL0$@`kDV$P%2SgBpHqM@L`HCr3d9HN+-(ml$w;`lyW%@ z#!&ts4#!J4XNFnyoWkYc4bDuqZd>ez9g@sqGG)LtGv9cmOf`$eF0)JMRM^TuHpzTS zzv*VNj7p_uG8i5F00U0Dtj8!7uJcPOK& zH-|ET(wNeHiCJW(vj3lBWET=6NzA07{y2xSf!r&26f5Iq%%-#=)|uvW>Taf7p}fxU zix^Kym31jm3;vVB{{GJ@{?F^VSb%>m|61WcY2?4Cvz`3E71zI2_&*u{DZ8QHGPCH* z@lX|iWcTf77Y)HqOBuJA{V#)x@;D=o%r}eKDO|}Y2Pk=zk10POtt$$EnG# zCOY49%DPkj6=mG2JLPv?x>LSo-JSCB%kGp9-O2Z=#_`oA>TKI`r#y>%`;YIGuW{We zZ?o}EdGL-q<=-%!bE>C}4&>sd-zoo)db=pS_TMRgkv0eLb*E73^k;Z4`Qfy?AJ0#| zQ@6a`>^tS(&bw3oDizDi>7-xh=~JiAK1IB(&^wSGK3=WmYAjaJ+4c%cwXglf8ms@O z{$jnY+5&&^k?kPW@7QhRll@nD9$abWk!1CrvfU~62Us>Z#qPjC#3@y6o17xIrhTDP zqz6?!Uq^ftw34o!zOGa0TpdwZXR(}`X(xy*dX>%!vSmh!_rh$+o#=TYOg5b#Zu_;P z*wd`;yhlZOGt2o-Vo`I;*-oM`qVCp6vAKmMB~napXR+iEa1&-$yGHJ2bw?os zb84(%U{lSP>7 zjEvu@zsW$qS4SH5mqWK32NyHn{b6et=aDtsJ;oEyEo{bi+E9Zu4_}WxDkFfJMxqc&v@2 zOKeQ6S48&81e)XEOBVEI$7Q*(ab6M!UuBr!I z49!YXYz1=_k&~oUJ;3lc)0OI%82)v-LZ??}W+*XoXvZw2hTKl4XDRmO3l;vtLa8o? zXU$gV@_gNFWvxLD9#2;4$~oOhR&27dd2>CDWzSWta@L1Yj$b=pvAsW^7t)*OD}S&s z=?fLxg@uZ;Z=s?UAhER!XDw2!%NHrioK(fSB2`g#ELN=N7AuOrM6sP)q9_?llpgo} zMuq!Cdyj4Qo>Jq8B0g6d$OEvpQk4%H1*JxoB0f~i2NWe+;RT?J{qOn5gtf&NEXWsz zT;+_vSY*1yd0A#3N_^Vi)2aJ^SjU=Yv}{wwX=6FFWSV;7XWISQVA-LH6r*jEDoTyE zW2*SdWZTIr)YGQb`t_I8#4tyM?{r0^E54@`B~zidb??HJudbpvUe@*~qEPW&Yfy@n z_WGiU>YT>DoV!_y?@x-dOQDaozgK5P^Az9d2BkoW&>wwSU8>t(;hmHnFRQin%P*^S z^cJtEEgV%8|9m$AD_315NZjD0xnc5KPnSjNMwWl0kM$!TWvh?e zYTiw@`o4O6&m>9RK)nq<)*U{|d;hO`%Tytjseb$EpFw?-dIx>1r+k!e{#LKHQZ3Km zn9bMA3JvdY^_Z(!R%^;q?XP*VGuas9wRMW}YW+;Poz(Xg&La1}|9)IurIyooxuQI& z%~q6W>J!aYd^w_)E9%-mn{c-u?ugUoC`x>Nj;|$(y7JGl?ioBVPPe|QF75C4sh{?R zpE&3j!jiDh{r<3P_v{=$PVewOi+0E&zOdBy4j;8^{1i@p#dwFfz%LwJaA;Q@qQnuR zzc@*KMxQo`-BdD3HOnZdy}XC~$RYKkqAfOvbMgo&U)@w`$bRM*!W6%6jN&_G8u1x} zw$~`m8u%V-zR_{Rs1)stnXEph1g@^31jhSv9~w<>bsH7iPDOcNU-p_hHzb3tonbV; zPtPmHSiU1=>am)BZ%h~1QQLgXpZO?z^vKuM-d1ad!Ex13Y&ZPQ6YHGU)e+TqaGdNg zS@|~IKGSllpVX|sY0A%@8%_S?Pnzu6e0f!! zM4Y1OL#C+xT>-P~bWkS5CyFJ>F6Nl5tLOR`iV`Z8HP+h@r7pG>?hvw`Bq^d*J8V45hpEmi2tw*6ylJdt-w$D)z4Py zr_8n5-nJ^st<@?{sPfhN=BetY_)po2o}QjReU-$c|fR zs+MgLv&^<#7O~t?X`e;BW3lbFh*FDXo}V(^@2?ZDeDzF4nR>|*_*FM?%KE7xx0_hu zzk+i*E6{IqcTv{RwzRvL+lU^I#8=I$&FLWyK4hK~CDyfcT!|97t)KfNN|dy5Oeb#t zu96-lZno-V`?ags+_eVdzwBCrF=gGX3%V=G-Q&2QcDGIIp|C6r)>T}MmHGoMJWmW^Q+%#iGY#UZVq>^m2^W0nr}~!$gr!(Yf-3!V`xqHHIg5huH;sl&z2o|V@Q z6XiW^e+(60^|Ee?7pHnzSI3Jhy==SVMR{*)>N8??-_`18!^E-2t)%uo9dvDkC>`QB zJX9j}z=;r?evD62=<$ow(FdO1N^njrJ*69#eA z>Az1`gI(|NO!JO5^;4@@Zmn^^D&DttcOQPMyD6R9*gmWxep9O64dkn5sqEXu=HOjb z#5eZ0jirHNYhaB}tMH^|+gn8x1lp-u9B5xwMI5bS{;`U3vdUk3pj@f8P7Qk7CU&cD z(Mq8;aDh!+w+>}Sdio5tn!ag^BEItJPMqcI_ko|-Q$6M?SNe)0{3^?lS~cHy z@_~1?YfdpI$Z?^LSQF$pU5Ag*Ymhk-6!>j02aV%su(%%NSQad1*LExo7AtDo?$s7= z*A6;Zn@&xZa*pwj8^WJ$?r^tx&)sH;yUjv(n`GQ=*8A5+*O~N1v(zT~u-WR8wr~6R zM7_maE|~mos6J`xU=lY}^9CO!MN_Y8>LINznfX5E)jmp^k4pTukC_{EmL5Jwt!_(K z>{&+fz0#G3^>qEQIcncl4=c^n4Sr`-PNdJ-@>h(O5BM7ht}X0+)qYwP8&vy#RlKiq z2+h-MD>ZJvJAav@u2)<<5Y!SmRLoI)ml#B@^0*Ld4Yq9tW%FOBqr7Xqpo|f3u@l}h zPTg!3Z>zRUtN21ar2J|XsXp@AbfwvP+$y%2ZTu?ge2d>FR4Nl>)2zXRGp)ZkVSIvL2xY$}bg%)-P{;u2z z6Ems}TNy5js=ZBWe$BS~!o;^VtA7zDeyhn7ns_THhInJ`N?BpzQti6Lg~65dFgm62 zTNWnP*R8TNOw4N#mB@(G4K*e%Z1_HtEo%G{@zN$$7ley9aXA&ujts+s(5)%>NZe5M9g+>JbktwS7V zHytsh|) z=i@jpKe(|S<(rhgw!M5h>C0U<$Je~itQ_*?HSjrK->bgLT5|}AV`ke=X61s}e8;R@ zGyi$b;wY6b8}#7|)M!@N7UGF z64R9?pPNL9!H3Jh9;1!8&{*q$N!&FyBtBvCEix%C{r!dNORj0`%V|c-cFtjA)pz+z zDWi?Y^50ChyCzX;a`HZe>i4FqY*IT{90ly|s$v>*wlycL=U=(LVUZeHKUJ|M8H`(%`pXT< zaiuYFlEIv6Q05v`GA^M^rMkX6RSnZ^sp=B_vs5*pYg1mx^%bIT;7x;gSMkd+h!lBU z5$g=L_Y9)g5VFLm%#%O+`Pbaa75~UeoRSkmc|KmD4ALK4thU|x#A3CjQt28Cea&Fo zY82Crw#7!VXy>XWYCWaeeAaZn(Uxx%tBf{|=q*Ou9;5hpXZ}*Pr{cfU!1tu-czUOQddUMEI%vMACgyIh**+HHwG(u(OnYL|-es-WJ~*f{E`KYVO>&LVh&3 ziU&z$r}@uSW>-1H6)Ij){CI&?rm&OTxg1X0oT}Q*s^asik9p4E5w;ga;uG$;@@m6N zKhk-k=JyldwAYT(fIDw}&rs=23tu8#!ud-(l{TpU7kRa+`dv`PIc`6%u2%bMb2!tL zoBH9^>J)#TTc6s-82m7e7~dX^@@JX0SW6t@j6=RoDpxw>7!N9&g2hJF zu_0Kj^YvdJEZ*~_t3k2W@tZX>SbSOAe{C?|X_KR`)#kum6l^729o+RF+p8r`aF;lt zK12_N$=WcX|DLXPtlh}Q!}r50D>kBkr2Uj4rrLQ0G+%j1@4G?m`p_yvJcrqBLo3g^ zIXHpo_*b#B>r&;h@7`yB%ju^#s4e^-sHg{TROeKcZx0<(>_-gZr1Gc`%MJD&29crf z*{IfVt>IcRfbHh&uZUg55qJb6u@JlAb$lFsTM97}gK#It;uTE6M`9HE0MBdB8` zzKz%M6b7+2A3eqkOMG$w?=ZFEOgv3cN+NEcA|f9a;!R9MEta=Aa0Z^iAJEEPYx}q& zqR@q7@gSxl9|VhRY>h=Y6Rqsk!`KQ(KBU%$C7gI2fB@A`ZqhT!cCJ30}lM z(9w(Q$!koH&tN<*#1!0%yLxf{UnD3ZQR{Uk;BM3tTj3Op$M-P>Uz@7X85^fh5=S$4 zz2RK_R^aIwOpjYZ!m|m1E0qX%)%ph1FvJdWR97BtT{&Yb8(Omj3v>B_pej% z6U;y(Z$Rf`GMf6cyLscf4_f*BAqD;UP$C~MV^9o_MGIK~v@BxF;M=Lx!y}7XaIC$A z>j<~j{Y$xyU}+i$rHi1`GBz15!ten?bk|t`6e~G+1`5$;HE-l A&$_yqdKvW3uz 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 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/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/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/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..56faa904c 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 @@ -80,10 +80,7 @@ internal class SessionCore( ): Outcome = try { Outcome.Ok(onNative { handle -> val result = native.finishPasskeyLogin( - handle, - credential.toNative(), - options.dcId, - options.authKeyId + handle, credential.toNative(), options.dcId, options.authKeyId ) maybeStartUpdates(handle) AuthState.Authorized(AuthSession(PeerId(result.userId), result.dcId)) @@ -111,8 +108,7 @@ internal class SessionCore( } return rpcWrite("registerPasskey failed") { native.registerPasskey( - it, - credential.toNative() + it, credential.toNative() ).toModel() } } @@ -143,8 +139,8 @@ internal class SessionCore( 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), + "tg://login?token=" + Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT) + .encode(result.token), result.expires, ) @@ -165,15 +161,14 @@ internal class SessionCore( } } - override suspend fun pollLoginToken(): Outcome = - try { - Outcome.Ok(onNative { handle -> native.pollLoginToken(handle) }) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - // Pending QR authorization belongs to AuthStore, not the signed-in account. - fail(e, "auth.pollLoginToken failed", degradeHome = false) - } + override suspend fun pollLoginToken(): Outcome = try { + Outcome.Ok(onNative { handle -> native.pollLoginToken(handle) }) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Pending QR authorization belongs to AuthStore, not the signed-in account. + fail(e, "auth.pollLoginToken failed", degradeHome = false) + } override suspend fun getDeviceSessions(): Outcome> { when (val connected = ensureConnected()) { @@ -203,8 +198,7 @@ internal class SessionCore( } return rpcWrite("revokeDeviceSession failed") { handle -> native.revokeDeviceSession( - handle, - hash + handle, hash ) } } @@ -223,6 +217,11 @@ internal class SessionCore( private var updatesStartedHandle: Long = 0L 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) @@ -234,8 +233,7 @@ internal class SessionCore( @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() @@ -360,9 +358,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) { @@ -387,14 +383,11 @@ internal class SessionCore( 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) + fallback: String, 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) @@ -403,15 +396,15 @@ internal class SessionCore( 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 } + val entry = current?.takeUnless { it.deferred.isCompleted } ?: SharedRead( + scope.async( + start = CoroutineStart.LAZY + ) { block() }).also { inflight[key] = it } entry.consumers++ entry } try { - @Suppress("UNCHECKED_CAST") - return (shared.deferred as Deferred).await() + @Suppress("UNCHECKED_CAST") return (shared.deferred as Deferred).await() } finally { withContext(NonCancellable) { inflightMutex.withLock { @@ -430,32 +423,31 @@ internal class SessionCore( dispatchClass: Int, degradeHome: Boolean, block: suspend (Long) -> T, - ): Outcome = - retryShortFlood { - try { - perfOp("bridge:${fallback.removeSuffix(" failed")}") { - Outcome.Ok(onNativeClass(dispatchClass, block)) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - if (isStaleHandleFailure(e)) { - AppLog.api("mtproto", "stale handle; recreating") - resetHandle() - try { - perfOp("bridge:${fallback.removeSuffix(" failed")}") { - Outcome.Ok(onNativeClass(dispatchClass, block)) - } - } catch (retry: CancellationException) { - throw retry - } catch (retry: Exception) { - fail(retry, fallback, degradeHome) + ): Outcome = retryShortFlood { + try { + perfOp("bridge:${fallback.removeSuffix(" failed")}") { + Outcome.Ok(onNativeClass(dispatchClass, block)) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (isStaleHandleFailure(e)) { + AppLog.api("mtproto", "stale handle; recreating") + resetHandle() + try { + perfOp("bridge:${fallback.removeSuffix(" failed")}") { + Outcome.Ok(onNativeClass(dispatchClass, block)) } - } else { - fail(e, fallback, degradeHome) + } catch (retry: CancellationException) { + throw retry + } catch (retry: Exception) { + fail(retry, fallback, degradeHome) } + } else { + fail(e, fallback, degradeHome) } } + } /** Skip native.connect (main lock) after a successful connect on this handle. */ internal suspend fun ensureConnected(): Outcome { @@ -512,9 +504,7 @@ internal class SessionCore( publishSyncState(activeHandle, true) native.startUpdates(activeHandle) native.updateLazySyncConfig( - activeHandle, - lazy = true, - exceptions = emptyList() + activeHandle, lazy = true, exceptions = emptyList() ) if (isCurrentHandle(activeHandle)) updatesStartedHandle = activeHandle PerfLog.trace("updates", "start", handle = activeHandle) @@ -535,10 +525,9 @@ internal class SessionCore( native.setTestDc(activeHandle, enabled) } - override suspend fun usesTestDc(): Outcome = - rpc("usesTestDc failed") { activeHandle -> - native.usesTestDc(activeHandle) - } + override suspend fun usesTestDc(): Outcome = rpc("usesTestDc failed") { activeHandle -> + native.usesTestDc(activeHandle) + } override suspend fun connect(): Outcome { if (sessionDead) return deadSessionError() @@ -550,12 +539,16 @@ 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 + var rejectedBindingHandle = 0L val connected = rpc("connect failed") { activeHandle -> refreshDcSidecar(sessionPath) try { @@ -563,6 +556,10 @@ internal class SessionCore( 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 @@ -571,6 +568,14 @@ internal class SessionCore( } 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 maybeStartUpdates(connected.value) @@ -700,29 +705,38 @@ 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") } + 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}") } + runCatching { native.setFilePartKib(kib) }.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}") } + runCatching { native.setDownloadChunkKib(kib) }.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 = 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..19f3923df 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 @@ -39,9 +39,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { 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 +52,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { completionHandle = 0L } } + private var chatsRefreshPending = false private var foldersRefreshPending = false @@ -61,20 +60,21 @@ 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 @@ -126,9 +126,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { publishCompletionIfReady(activeHandle) val drainAt = if (PerfLog.isEnabled()) System.nanoTime() else 0L val drained = nativeRequest( - core.native, - core.nativeDispatcher, - DispatchClass.BACKGROUND_READ + core.native, core.nativeDispatcher, DispatchClass.BACKGROUND_READ ) { core.native.drainUpdateBatches(activeHandle) } @@ -158,18 +156,14 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { if (core.durableSink == null) drainDelayMs = REPLAY_WAIT_MS } if (core.isCurrentHandle(activeHandle)) drained else UpdateDrainDto( - emptyList(), - emptyList(), - false + 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 +174,26 @@ 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)) @@ -234,16 +223,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)) { + if (chats != null && core.isCurrentHandle(activeHandle)) { lastChatsRefreshMs.set(core.clock.elapsedMs()) chatsRefreshPending = false core.updatesEvents.emit(MtprotoUpdate.ChatsChanged(chats)) @@ -258,9 +247,9 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } } } - 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 +265,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)) @@ -301,11 +290,10 @@ 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 committed = try { sink.commit( @@ -315,10 +303,7 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { updates = batch.events.flatMap { it.toMtprotoUpdates() }, cursor = batch.cursorAfter?.let { UpdatesCursor( - it.pts, - it.qts, - it.date, - it.seq + it.pts, it.qts, it.date, it.seq ) }, channelCursors = batch.channelPtsAfter.associate { it.chatId to it.pts }, @@ -331,10 +316,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) + 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 +346,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 } @@ -410,5 +404,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..7296982e0 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 @@ -1120,6 +1120,7 @@ class BridgedMtprotoClientLifecycleTest { 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( 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/MediaRepository.kt b/network/http/src/main/java/org/monogram/network/http/MediaRepository.kt index 3417f1b54..4aea2f5fc 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 @@ -56,11 +57,8 @@ fun peerPhotoSmallCacheKey(photoCacheKey: String): String = } internal fun streamPrefixPart( - directory: File, - cacheKey: String, - offset: Long -): File = - FileCache(directory, maintainOnInit = false).fileFor(streamPartKey(cacheKey, offset)) + directory: File, cacheKey: String, offset: Long +): File = FileCache(directory, maintainOnInit = false).fileFor(streamPartKey(cacheKey, offset)) internal fun streamPartKey(cacheKey: String, offset: Long): String = "$cacheKey:stream:v2:$offset" @@ -70,9 +68,7 @@ internal fun streamMediaKey(message: Message): String? = message.mediaCacheKey?. /** A prefix part the player can read without fetching again. */ internal fun reusableStreamPrefix( - file: File?, - offset: Long = 0, - size: Long? = null + file: File?, offset: Long = 0, size: Long? = null ): File? { val expected = size?.takeIf { it > 0 }?.let { minOf(512L * 1024, it - offset) } ?: 512L * 1024 return file?.takeIf { expected > 0 && it.isFile && it.length() == expected } @@ -119,7 +115,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 +131,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 +171,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() @@ -187,13 +186,13 @@ class MediaRepository( 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()) { @@ -241,13 +240,9 @@ class MediaRepository( * 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() + 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 } } @@ -302,6 +297,16 @@ 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}" @@ -321,6 +326,7 @@ class MediaRepository( fun clearCache(): Long { val freed = cache.clear() + synchronized(inlineThumbs) { inlineThumbs.clear() } markAllCachedFilesChanged() return freed } @@ -363,18 +369,16 @@ 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 + message: Message, priority: Int, retain: 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, @@ -430,10 +434,7 @@ class MediaRepository( val temporary = File.createTempFile("stream-$offset-", ".part", streamRoot) try { when (val result = fetcher.fetch( - message.id.chatId, - message.id.id, - temporary.absolutePath, - offset + message.id.chatId, message.id.id, temporary.absolutePath, offset )) { is Outcome.Err -> { temporary.delete() @@ -443,11 +444,9 @@ 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 +459,9 @@ 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) @@ -485,9 +485,7 @@ class MediaRepository( } private suspend fun prefetchMp4Metadata( - message: Message, - key: String, - fetcher: TelegramChunkFetcher + message: Message, key: String, fetcher: TelegramChunkFetcher ) { val size = message.fileSize?.takeIf { it > 0 } ?: return var remaining = 2L * 1024 * 1024 @@ -506,10 +504,7 @@ class MediaRepository( val temporary = File.createTempFile("metadata-", ".part", streamRoot) try { when (val result = fetcher.fetch( - message.id.chatId, - message.id.id, - temporary.absolutePath, - aligned + message.id.chatId, message.id.id, temporary.absolutePath, aligned )) { is Outcome.Err -> return null is Outcome.Ok -> if (File(result.value).canonicalFile != temporary.canonicalFile) return null @@ -518,10 +513,7 @@ class MediaRepository( java.io.RandomAccessFile(temporary, "rw").use { it.fd.sync() } return runCatching { cache.putFile( - partKey, - temporary, - message.id.chatId.value, - FileCache.KIND_VIDEOS + partKey, temporary, message.id.chatId.value, FileCache.KIND_VIDEOS ) }.getOrNull() } finally { @@ -536,8 +528,7 @@ class MediaRepository( val position = offset + copied val file = part(position) ?: break val take = minOf( - (count - copied).toLong(), - file.length() - (position % STREAM_PART) + (count - copied).toLong(), file.length() - (position % STREAM_PART) ).toInt() if (take <= 0) break java.io.RandomAccessFile(file, "r").use { input -> @@ -573,23 +564,20 @@ class MediaRepository( scheduled: ScheduledMessage, priority: Int = MediaPriority.THUMB, ): Outcome = ensureMessageThumb( - scheduled.message.copy(id = scheduled.mediaMessageId), - priority, - scheduled = true + scheduled.message.copy(id = scheduled.mediaMessageId), priority, scheduled = true ) private suspend fun ensureMessageThumb( - message: Message, - priority: Int, - scheduled: Boolean + message: Message, priority: Int, 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, @@ -623,15 +611,13 @@ class MediaRepository( scheduleFullAvatar(peerId, cacheKey) 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) return small @@ -646,15 +632,13 @@ class MediaRepository( peerId: PeerId, cacheKey: String, priority: Int, - ): Outcome = when ( - val fetched = ensureLocalTelegramMedia( - key = cacheKey, - chatId = peerId, - messageId = 0, - kind = MediaFetchKind.Full, - priority = priority, - ) - ) { + ): Outcome = when (val fetched = ensureLocalTelegramMedia( + key = cacheKey, + chatId = peerId, + messageId = 0, + kind = MediaFetchKind.Full, + priority = priority, + )) { is Outcome.Ok -> { rememberPeerAvatar(peerId, fetched.value) fetched @@ -722,35 +706,36 @@ 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, + 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") }, ) - }.fold( - onSuccess = { - markCached(key) - Outcome.Ok(it) - }, - onFailure = { Outcome.Err(it.message ?: "cache write failed") }, - ) + } } + + is Outcome.Err -> fetched } - is Outcome.Err -> fetched } - } if (result is Outcome.Err) { AppLog.api("media", "err kind=Emoji key=emoji:$documentId ${result.message}") } @@ -850,14 +835,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 +886,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 } @@ -960,11 +943,7 @@ class MediaRepository( 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) + queued.generation == job.generation && !job.running && !job.cancelled && !job.deferred.isCompleted && job.priority >= minPriority && (thumbnail == null || job.thumbnail == thumbnail) } private suspend fun applyDownloadConsumerPriority( @@ -1080,8 +1059,7 @@ class MediaRepository( val dest = cache.fileFor(key) telegramFetcher?.promote( File( - dest.parentFile, - "${dest.name}.part" + dest.parentFile, "${dest.name}.part" ).absolutePath, priority ) } @@ -1263,8 +1241,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, @@ -1333,9 +1310,11 @@ class MediaRepository( 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)) } @@ -1394,6 +1373,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 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..330f46cd7 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 @@ -10,6 +10,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 @@ -24,6 +25,19 @@ class MediaRepositoryThumbTest { @get:Rule val tmp = TemporaryFolder() + @Test + 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 ensureLocalMessageThumbAlwaysRequestsThumb() = runBlocking { var lastThumb: Boolean? = null @@ -264,4 +278,29 @@ 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..59b4229b0 160000 --- a/vendor/mono-rs +++ b/vendor/mono-rs @@ -1 +1 @@ -Subproject commit 0bc5195bf004d7fec8038626edf680c5edbdb906 +Subproject commit 59b4229b0c8506f5852977aea833d62aa6630a97 From 8426e55a6db1bedb23f88bb055d17d28f5f69d6c Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:12:24 +0300 Subject: [PATCH 2/9] fix compiling --- .../src/main/java/org/monogram/feature/chats/ui/ChatsContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b12795d06..d3369f589 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 @@ -597,7 +597,7 @@ fun ChatsContent( .weight(1f) .fillMaxSize() ) { - AnimatedVisibility( + androidx.compose.animation.AnimatedVisibility( visible = state.loading && paneEmpty && !folderMoving, modifier = Modifier.fillMaxSize(), enter = fadeIn(animationSpec = tween(180)), From 2d1a2d0a27d0e9acffa275c8ac19778854a2834e Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:04:30 +0300 Subject: [PATCH 3/9] fix(account): account navigation and stale notifications --- .../monogram/push/ConversationShadeTest.kt | 41 ++++++++ .../main/java/org/monogram/AccountRegistry.kt | 3 + .../main/java/org/monogram/MainActivity.kt | 87 ++++++++++++++++- app/src/main/java/org/monogram/MonogramApp.kt | 2 + .../monogram/push/NotificationPresenter.kt | 29 ++++++ .../monogram/push/NotificationReadGuard.kt | 37 +++++++ .../java/org/monogram/push/PushCoordinator.kt | 91 +++++++++++------ .../monogram/root/AccountDurableUpdateSink.kt | 45 +++++---- .../java/org/monogram/root/RootComponent.kt | 31 ++++++ .../monogram/AccountNavigationIntentTest.kt | 96 ++++++++++++++++++ .../java/org/monogram/AccountRegistryTest.kt | 45 +++++++-- .../push/NotificationReadGuardTest.kt | 97 +++++++++++++++++++ .../org/monogram/root/TabletNavigationTest.kt | 35 +++++++ .../monogram/core/common/push/PushPayload.kt | 2 + .../core/common/push/PushPayloadTest.kt | 12 +++ 15 files changed, 593 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/org/monogram/push/NotificationReadGuard.kt create mode 100644 app/src/test/java/org/monogram/AccountNavigationIntentTest.kt create mode 100644 app/src/test/java/org/monogram/push/NotificationReadGuardTest.kt diff --git a/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt b/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt index 39424a853..0c3aa5430 100644 --- a/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt +++ b/app/src/androidTest/java/org/monogram/push/ConversationShadeTest.kt @@ -51,6 +51,47 @@ class ConversationShadeTest { 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) diff --git a/app/src/main/java/org/monogram/AccountRegistry.kt b/app/src/main/java/org/monogram/AccountRegistry.kt index 8555f72b8..80255856a 100644 --- a/app/src/main/java/org/monogram/AccountRegistry.kt +++ b/app/src/main/java/org/monogram/AccountRegistry.kt @@ -52,6 +52,9 @@ class AccountRegistry( } } 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 -> diff --git a/app/src/main/java/org/monogram/MainActivity.kt b/app/src/main/java/org/monogram/MainActivity.kt index 05f0698e2..4e3b6f870 100644 --- a/app/src/main/java/org/monogram/MainActivity.kt +++ b/app/src/main/java/org/monogram/MainActivity.kt @@ -62,10 +62,18 @@ 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. */ @@ -113,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() @@ -139,7 +154,7 @@ open class MainActivity : ComponentActivity() { } lifecycleScope.launch { app.awaitReady() - if (intent.action == NotificationPresenter.ACTION_OPEN_CHAT || intent.action == NotificationPresenter.ACTION_OPEN_NOTIFICATIONS) { + if (!notificationIntentHandled && isNotificationIntent(intent.action)) { when (val route = notificationAccountRoute( intent.getLongExtra(NotificationPresenter.EXTRA_ACCOUNT_USER_ID, 0L), app.accountRegistry.activeUserId, @@ -188,6 +203,9 @@ open class MainActivity : ComponentActivity() { sessionStore = bindings.sessionStore, mediaRepository = bindings.mediaRepository, startOnHome = startOnHome, + accountSwitchDestination = accountSwitchDestination.takeIf { + accountSwitchUserId == bindings.account?.userId + }, pushRegistration = bindings.push, notificationLocal = bindings.notifications, onIncomingShareConsumed = { pendingIncomingShare = null }, @@ -226,7 +244,10 @@ open class MainActivity : ComponentActivity() { activeAccountUserId = bindings.account?.userId, activeAccountDcId = bindings.account?.dcId, onSwitchAccount = { userId -> + val destination = root.accountSwitchDestination() app.switchAccount(userId) + accountSwitchUserId = userId + accountSwitchDestination = destination recreate() }, onRemoveAccount = { userId -> @@ -234,9 +255,17 @@ open class MainActivity : ComponentActivity() { 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 @@ -297,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), @@ -353,7 +390,7 @@ open class MainActivity : ComponentActivity() { } return } - if (intent.action == NotificationPresenter.ACTION_OPEN_CHAT || intent.action == NotificationPresenter.ACTION_OPEN_NOTIFICATIONS) { + if (isNotificationIntent(intent.action)) { val registry = (application as MonogramApp).accountRegistry val accountId = intent.getLongExtra(NotificationPresenter.EXTRA_ACCOUNT_USER_ID, 0L) when (val route = notificationAccountRoute( @@ -361,7 +398,10 @@ open class MainActivity : ComponentActivity() { registry.activeUserId, registry.all(), )) { - NotificationAccountRoute.Reject -> return + NotificationAccountRoute.Reject -> { + notificationIntentHandled = true + return + } NotificationAccountRoute.Open -> Unit is NotificationAccountRoute.Switch -> { lifecycleScope.launch { @@ -371,6 +411,7 @@ open class MainActivity : ComponentActivity() { return } } + notificationIntentHandled = true } if (intent.action == NotificationPresenter.ACTION_OPEN_CHAT) { val chatId = intent.getLongExtra(NotificationPresenter.EXTRA_CHAT_ID, 0L) @@ -384,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 d873f5df3..315c9d105 100644 --- a/app/src/main/java/org/monogram/MonogramApp.kt +++ b/app/src/main/java/org/monogram/MonogramApp.kt @@ -276,6 +276,7 @@ class MonogramApp : Application() { accountRegistry.all().map { it.userId } }, sharedPushSecret = sharedPushSecret::get, + notificationCache = runtime.warmup, ) createdCoordinator = coordinator coordinator.setAccountUserId(userId) @@ -954,6 +955,7 @@ class MonogramApp : Application() { accountRegistry.all().map { it.userId } }, sharedPushSecret = sharedPushSecret::get, + notificationCache = warmup, ) } pushRouter = AccountPushRouter() diff --git a/app/src/main/java/org/monogram/push/NotificationPresenter.kt b/app/src/main/java/org/monogram/push/NotificationPresenter.kt index 43c85d6a3..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" @@ -97,6 +100,32 @@ object NotificationPresenter { 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, 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 18caf974f..cfd8854be 100644 --- a/app/src/main/java/org/monogram/push/PushCoordinator.kt +++ b/app/src/main/java/org/monogram/push/PushCoordinator.kt @@ -61,6 +61,7 @@ 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 @@ -121,6 +122,7 @@ class PushCoordinator( 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) @@ -293,6 +295,7 @@ class PushCoordinator( override fun setAccountUserId(userId: Long) { accountUserId = userId + if (userId > 0L) NotificationPresenter.clearLegacyNotifications(context) } override fun distributors(): List = unifiedPushDistributors() @@ -740,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) } @@ -849,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() @@ -959,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, @@ -993,11 +1027,20 @@ class PushCoordinator( 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, - accountUserId = accountUserId, - ) + 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 } @@ -1015,17 +1058,12 @@ class PushCoordinator( } } avatarFile(chatId)?.let { file -> - NotificationPresenter.show( - context, - payload, - decision, - folderId, - file, - mode, - badge, - quiet = true, - accountUserId = accountUserId, - ) + withUnreadPayload(payload) { + NotificationPresenter.show( + context, payload, decision, folderId, file, mode, badge, + quiet = true, accountUserId = accountUserId, + ) + } return@launch } @@ -1034,17 +1072,12 @@ class PushCoordinator( is Outcome.Ok -> result.value is Outcome.Err -> null } ?: return@launch - NotificationPresenter.show( - context, - payload, - decision, - folderId, - file, - mode, - badge, - quiet = true, - accountUserId = accountUserId, - ) + withUnreadPayload(payload) { + NotificationPresenter.show( + context, payload, decision, folderId, file, mode, badge, + quiet = true, accountUserId = accountUserId, + ) + } } } diff --git a/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt b/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt index b1f908f74..b495f2200 100644 --- a/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt +++ b/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt @@ -8,6 +8,7 @@ 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 { @@ -21,27 +22,29 @@ internal class AccountDurableUpdateSink(private val cache: OfflineWarmup) { ) } 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 { update -> - if (update is MtprotoUpdate.OutgoingMessageAcknowledged && update.randomId in mediaIds) return@forEach - 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 } + val committed = NotificationReadGuard.withAccount(cache) { + 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 { update -> + if (update is MtprotoUpdate.OutgoingMessageAcknowledged && update.randomId in mediaIds) return@forEach + 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()) { diff --git a/app/src/main/java/org/monogram/root/RootComponent.kt b/app/src/main/java/org/monogram/root/RootComponent.kt index a1a6904a7..e303ce21d 100644 --- a/app/src/main/java/org/monogram/root/RootComponent.kt +++ b/app/src/main/java/org/monogram/root/RootComponent.kt @@ -56,6 +56,7 @@ 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 @@ -72,6 +73,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, @@ -151,6 +153,13 @@ 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 } @@ -327,6 +336,9 @@ class RootComponent( } init { + if (startOnHome && accountSwitchDestination != null && bubbleChatId == null) { + navigation.navigate { accountSwitchStack(accountSwitchDestination) } + } if (restoringSignedOutStack && stack.value.items.any { !it.configuration.isAuthArea() }) { navigation.replaceAll(Config.Auth) } @@ -820,6 +832,25 @@ 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, 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 index f9a73cb39..255969d47 100644 --- a/app/src/test/java/org/monogram/AccountRegistryTest.kt +++ b/app/src/test/java/org/monogram/AccountRegistryTest.kt @@ -38,10 +38,33 @@ class AccountRegistryTest { 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() } @@ -53,6 +76,8 @@ class AccountRegistryTest { 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)) { @@ -67,6 +92,7 @@ class AccountRegistryTest { assertNotNull(runCatching { AccountRegistry(root).load() }.exceptionOrNull()) assertFalse(primary.exists()) assertEquals(invalidState, backup.readText()) + assertEquals("interrupted", staged.readText()) } finally { root.deleteRecursively() } @@ -108,7 +134,7 @@ class AccountRegistryTest { } @Test - fun existingStagedRegistryIsNotOverwrittenByNextWrite() { + fun existingStagedRegistryIsRemovedAfterFailedWriteRecovery() { val root = Files.createTempDirectory("monogram-registry-staged-write").toFile() try { val registry = AccountRegistry(root) @@ -125,9 +151,10 @@ class AccountRegistryTest { ) ) }.exceptionOrNull()) - assertEquals(contents, staged.readText()) + assertFalse(staged.exists()) assertEquals(listOf(11L), registry.all().map { it.userId }) - assertEquals(listOf(11L), AccountRegistry(root).load().map { it.userId }) + registry.ensure(AuthSession(PeerId(22), 4)) + assertEquals(listOf(11L, 22L), AccountRegistry(root).load().map { it.userId }) } finally { root.deleteRecursively() } @@ -149,9 +176,15 @@ class AccountRegistryTest { ) ) }.exceptionOrNull()) - assertEquals(listOf(11L), registry.all().map { it.userId }) + 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) - assertEquals(listOf(11L), AccountRegistry(root).load().map { it.userId }) + registry.ensure(AuthSession(PeerId(22), 4)) + assertEquals(listOf(11L, 22L), AccountRegistry(root).load().map { it.userId }) } finally { root.deleteRecursively() } @@ -182,7 +215,7 @@ class AccountRegistryTest { primary.writeText(durable) assertEquals(listOf(11L), registry.load().map { it.userId }) assertEquals(11L, registry.activeUserId) - assertEquals("interrupted", staged.readText()) + assertFalse(staged.exists()) } finally { root.deleteRecursively() } 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 f531d94fc..4f27d7461 100644 --- a/app/src/test/java/org/monogram/root/TabletNavigationTest.kt +++ b/app/src/test/java/org/monogram/root/TabletNavigationTest.kt @@ -10,6 +10,41 @@ class TabletNavigationTest { 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/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/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( From 6b7447a4e3b7dfd2ceb74a99662fa757b0b7b41c Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:27:35 +0300 Subject: [PATCH 4/9] Update mono-rs --- vendor/mono-rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/mono-rs b/vendor/mono-rs index 59b4229b0..3a4c49a70 160000 --- a/vendor/mono-rs +++ b/vendor/mono-rs @@ -1 +1 @@ -Subproject commit 59b4229b0c8506f5852977aea833d62aa6630a97 +Subproject commit 3a4c49a70e8723baf4499ab1f1e0ec5d289198f8 From ec2083814dcc8ff57b34efa7184ee4babaa5f736 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:28:20 +0300 Subject: [PATCH 5/9] perf: optimize chat loading, persistence, and transfer recovery --- app/build.gradle.kts | 47 +- .../java/org/monogram/ComposerEditingTest.kt | 38 +- .../org/monogram/NetcodePerfHarnessTest.kt | 795 ++-- app/src/main/java/org/monogram/MonogramApp.kt | 23 +- .../main/java/org/monogram/StartupSequence.kt | 4 +- .../monogram/root/AccountDurableUpdateSink.kt | 201 +- .../org/monogram/root/OutgoingTextSync.kt | 6 +- .../java/org/monogram/root/RootComponent.kt | 38 - .../java/org/monogram/root/RootContent.kt | 1 - .../org/monogram/update/GitHubAppUpdate.kt | 5 +- .../java/org/monogram/StartupSequenceTest.kt | 64 + .../monogram/core/database/OfflineWarmup.kt | 1985 +++++---- .../org/monogram/core/database/dao/ChatDao.kt | 90 +- .../org/monogram/core/database/dao/MetaDao.kt | 9 + .../core/database/dao/OutgoingMediaSendDao.kt | 3 + .../core/database/dao/OutgoingTextSendDao.kt | 3 + .../monogram/core/ui/components/PeerAvatar.kt | 145 +- .../feature/chats/CatchupLoadingDeviceTest.kt | 19 +- .../monogram/feature/chats/ChatsExecutor.kt | 64 +- .../monogram/feature/chats/ui/ChatsContent.kt | 20 +- .../feature/chats/ChatsStoreCacheTest.kt | 3792 +++++++++++------ .../dialog/HistoryLoadingDeviceTest.kt | 51 +- .../feature/dialog/store/DialogExecutor.kt | 22 +- .../feature/dialog/store/DialogHistory.kt | 2 +- .../feature/dialog/ui/DialogRenderCache.kt | 64 +- .../feature/dialog/ui/MessageBubble.kt | 955 +++-- .../feature/dialog/ui/MessageMediaStill.kt | 102 +- .../feature/dialog/ui/SwipeToReply.kt | 41 +- .../dialog/ui/TextWithTimestampLayout.kt | 32 +- .../feature/dialog/DialogForumStoreTest.kt | 194 + .../feature/settings/ui/SettingsContent.kt | 679 +-- .../feature/settings/ui/SettingsHome.kt | 281 +- gradle/wrapper/gradle-wrapper.properties | 4 +- native/Cargo.lock | 2 - .../jniLibs/arm64-v8a/libmonogram_markup.so | Bin 989328 -> 989344 bytes .../jniLibs/armeabi-v7a/libmonogram_markup.so | Bin 804564 -> 804628 bytes .../main/jniLibs/x86_64/libmonogram_markup.so | Bin 1018696 -> 1018696 bytes native/mtproto-rs/src/ffi.rs | 2 +- native/mtproto-rs/src/lib.rs | 4 +- native/mtproto-rs/src/lottie.rs | 2 +- native/mtproto-rs/src/vpx.rs | 14 +- .../monogram/network/bridge/MtprotoClient.kt | 6 +- .../monogram/network/bridge/chat/ChatApi.kt | 163 +- .../network/bridge/message/MessageApi.kt | 690 +-- .../network/bridge/session/DcTxtBootstrap.kt | 19 +- .../network/bridge/session/SessionCore.kt | 588 ++- .../network/bridge/updates/UpdatesPump.kt | 373 +- .../BridgedMtprotoClientLifecycleTest.kt | 3218 ++++++++------ .../monogram/network/http/DownloadQueue.kt | 45 +- .../org/monogram/network/http/FileCache.kt | 587 +-- .../org/monogram/network/http/MapTiles.kt | 80 +- .../monogram/network/http/MediaRepository.kt | 901 ++-- .../network/http/MediaRepositoryThumbTest.kt | 608 ++- 53 files changed, 10690 insertions(+), 6391 deletions(-) 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/main/java/org/monogram/MonogramApp.kt b/app/src/main/java/org/monogram/MonogramApp.kt index 315c9d105..12c5058f5 100644 --- a/app/src/main/java/org/monogram/MonogramApp.kt +++ b/app/src/main/java/org/monogram/MonogramApp.kt @@ -707,6 +707,7 @@ class MonogramApp : Application() { super.onCreate() ComposeMaterial3Flags.isCheckboxStylingFixEnabled = true val startedAt = PerfLog.nowMs() + PerfLog.event("startup:begin") perfSpan("app:settings") { AppLog.init(cacheDir) } @@ -738,7 +739,7 @@ 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) @@ -768,6 +769,7 @@ class MonogramApp : Application() { fun currentMediaRoot(): File = currentAccount()?.mediaPath ?: durableMedia lateinit var db: MonogramDatabase + lateinit var startupWarmup: OfflineWarmup return StartupSteps( openDatabase = { accountRegistry = AccountRegistry(filesDir).also { it.load() } @@ -849,6 +851,7 @@ class MonogramApp : Application() { AppLog.warn("proxy", "stored proxy settings rejected") } } + startupWarmup = warmup }, installImageCache = { perfSpan("app:imageCache") { ImageCache.install(this) } @@ -870,10 +873,20 @@ class MonogramApp : Application() { ) } } - runCatching { warmup.prepareStartupSnapshot(sessionStore) }.onFailure { - AppLog.warn( - "warmup", "startup cleanup failed" - ) + 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 = { diff --git a/app/src/main/java/org/monogram/StartupSequence.kt b/app/src/main/java/org/monogram/StartupSequence.kt index 4c8b0592e..273342d00 100644 --- a/app/src/main/java/org/monogram/StartupSequence.kt +++ b/app/src/main/java/org/monogram/StartupSequence.kt @@ -14,6 +14,7 @@ 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, @@ -43,7 +44,8 @@ internal suspend fun runFirstPaintStartup( } } -internal suspend fun startAfterFirstPaint(steps: StartupSteps) { +internal suspend fun startAfterFirstPaint(steps: StartupSteps, background: CoroutineScope) { + background.launch { steps.maintainCache() } steps.startPush() steps.startAccounts() steps.startSponsor() diff --git a/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt b/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt index b495f2200..09c92efd2 100644 --- a/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt +++ b/app/src/main/java/org/monogram/root/AccountDurableUpdateSink.kt @@ -10,7 +10,9 @@ import org.monogram.network.bridge.DurableUpdateBatch import org.monogram.network.bridge.MtprotoUpdate import org.monogram.push.NotificationReadGuard -internal class AccountDurableUpdateSink(private val cache: OfflineWarmup) { +internal class AccountDurableUpdateSink( + private val cache: OfflineWarmup, +) { suspend fun commit(batch: DurableUpdateBatch): Boolean { val cursor = batch.cursor?.let { UpdateCursorEntity( @@ -18,30 +20,56 @@ internal class AccountDurableUpdateSink(private val cache: OfflineWarmup) { it.pts, it.qts, it.date, - it.seq + it.seq, ) } val releasedSources = mutableListOf() val committed = NotificationReadGuard.withAccount(cache) { cache.applyDurableBatch( - batch.accountGeneration, batch.batchId, cursor, batch.channelCursors + 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 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() - batch.updates.forEach { update -> - if (update is MtprotoUpdate.OutgoingMessageAcknowledged && update.randomId in mediaIds) return@forEach - apply(update, batch.accountGeneration, batch.batchId) + 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, + cache.outgoingAccountEpoch(), + batch.accountGeneration, + batch.batchId, ) releasedSources += send.items.map { it.source } } @@ -59,75 +87,118 @@ internal class AccountDurableUpdateSink(private val cache: OfflineWarmup) { return committed } - private suspend fun apply(update: MtprotoUpdate, generation: Long, order: Long) { + private suspend fun apply( + update: MtprotoUpdate, + generation: Long, + order: Long, + ) { when (update) { - is MtprotoUpdate.NewMessage -> cache.applyIncomingMessage( - update.message, - generation, - order - ) + is MtprotoUpdate.NewMessage -> { + cache.applyIncomingMessage( + update.message, + generation, + order, + ) + } - is MtprotoUpdate.OutgoingMessageAcknowledged -> cache.applyOutgoingResult( - update.randomId, - update.result, - 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.ScheduledMessageChanged -> { + cache.applyScheduledMessage(update.message) + } - 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.ScheduledMessagesDeleted -> { + cache.deleteScheduledMessages( + update.chatId, + update.messageIds, + ) + } - is MtprotoUpdate.MessageReactions -> cache.applyReactions( - update.chatId, - update.messageId, - update.reactionsJson - ) + is MtprotoUpdate.DraftChanged -> { + cache.applyCloudDraft(update.draft) + } - is MtprotoUpdate.ReadOutbox -> cache.applyOutboxRead(update.chatId, update.maxId) - is MtprotoUpdate.DiscussionInbox -> cache.applyDiscussionRead( - update.channelId, - update.topMessageId, - update.readMaxId - ) + is MtprotoUpdate.MessageEdited -> { + cache.applyMessageEdit(update.message) + } - is MtprotoUpdate.ReadInbox -> cache.applyInboxRead( - update.chatId, - update.maxId, - update.stillUnread - ) + 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.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.UnreadMentionsDelta -> { + cache.addUnreadMentions( + update.chatId, + update.delta, + ) + } - is MtprotoUpdate.UnreadReactionsDelta -> cache.addUnreadReactions( - update.chatId, - update.delta - ) + is MtprotoUpdate.UnreadReactionsDelta -> { + cache.addUnreadReactions( + update.chatId, + update.delta, + ) + } - else -> Unit + else -> { + Unit + } } } } diff --git a/app/src/main/java/org/monogram/root/OutgoingTextSync.kt b/app/src/main/java/org/monogram/root/OutgoingTextSync.kt index a1e45ad0a..6f25b3f36 100644 --- a/app/src/main/java/org/monogram/root/OutgoingTextSync.kt +++ b/app/src/main/java/org/monogram/root/OutgoingTextSync.kt @@ -279,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) @@ -295,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 e303ce21d..b9290a40c 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 @@ -28,8 +27,6 @@ import org.monogram.BuildConfig import org.monogram.MonogramApp 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 @@ -49,7 +46,6 @@ 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.scheduled.ScheduledQueueComponent import org.monogram.feature.folders.FoldersComponent import org.monogram.feature.profile.ProfileComponent @@ -62,7 +58,6 @@ 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( @@ -100,36 +95,6 @@ class RootComponent( ) : 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" - ) - }, - ) - - fun noteFirstFrame() { - targetWarmup.onFirstFrame() - } - private var pendingAdditionalAuthorization: MonogramApp.PendingAccountAuthorization? = null fun beginAddAccount() { @@ -168,7 +133,6 @@ class RootComponent( fun openFromNotification(chatId: Long, messageId: Int = 0) { if (stack.value.active.configuration.isAuthArea()) return - targetWarmup.select(chatId, messageId) AppLog.api("notify", "open chat=$chatId") pushRegistration?.onVisibleChat(chatId) if (bubbleChatId != null) { @@ -222,7 +186,6 @@ class RootComponent( } private fun openChatFromList(peer: PeerId, forum: Boolean, messageId: Int = 0) { - targetWarmup.select(peer.value, messageId) val destination = Config.Dialog( chatId = peer.value, isForum = forum, @@ -612,7 +575,6 @@ class RootComponent( ) is Config.Dialog -> { - targetWarmup.promote(config.chatId) val liveDialogEpoch = warmup?.outgoingAccountEpoch() val component = DialogComponent( componentContext = context, diff --git a/app/src/main/java/org/monogram/root/RootContent.kt b/app/src/main/java/org/monogram/root/RootContent.kt index e5acf65bc..65fba0a0c 100644 --- a/app/src/main/java/org/monogram/root/RootContent.kt +++ b/app/src/main/java/org/monogram/root/RootContent.kt @@ -127,7 +127,6 @@ private fun RootPlaybackContent( 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) } 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/StartupSequenceTest.kt b/app/src/test/java/org/monogram/StartupSequenceTest.kt index 44053ab61..1937a378b 100644 --- a/app/src/test/java/org/monogram/StartupSequenceTest.kt +++ b/app/src/test/java/org/monogram/StartupSequenceTest.kt @@ -5,6 +5,7 @@ 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 @@ -58,6 +59,69 @@ class StartupSequenceTest { 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) 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 81b7240bb..0b8924477 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 @@ -65,25 +65,30 @@ open class OfflineWarmup( val outgoingSendSync = OutgoingSendSyncMonitor() private val activeContactImports = mutableMapOf, Long>() private val draftWriteMutex = Mutex() - private val outgoingAccountEpoch = java.util.concurrent.atomic.AtomicLong() + private val outgoingAccountEpoch = + java.util.concurrent.atomic + .AtomicLong() @Volatile private var outgoingAccountActive = true fun outgoingAccountEpoch(): Long = outgoingAccountEpoch.get() + fun outgoingAccountIsCurrent(epoch: Long): Boolean = outgoingAccountActive && outgoingAccountEpoch.get() == epoch - suspend fun deactivateOutgoingAccount() = draftWriteMutex.withLock { - outgoingAccountActive = false - outgoingAccountEpoch.incrementAndGet() - Unit - } + suspend fun deactivateOutgoingAccount() = + draftWriteMutex.withLock { + outgoingAccountActive = false + outgoingAccountEpoch.incrementAndGet() + Unit + } - suspend fun activateOutgoingAccount() = draftWriteMutex.withLock { - if (!outgoingAccountActive) outgoingAccountEpoch.incrementAndGet() - outgoingAccountActive = true - } + suspend fun activateOutgoingAccount() = + draftWriteMutex.withLock { + if (!outgoingAccountActive) outgoingAccountEpoch.incrementAndGet() + outgoingAccountActive = true + } @Volatile private var firstPaintSnapshot: OfflineWarmupSnapshot? = null @@ -96,31 +101,26 @@ open class OfflineWarmup( fun startupSnapshot(): OfflineWarmupSnapshot? = firstPaintSnapshot - fun takeStartupSnapshot(): OfflineWarmupSnapshot? = synchronized(this) { - firstPaintSnapshot.also { firstPaintSnapshot = null } - } + fun takeStartupSnapshot(): OfflineWarmupSnapshot? = + synchronized(this) { + firstPaintSnapshot.also { firstPaintSnapshot = null } + } suspend fun prepareStartupSnapshot(sessionStore: SessionMetadataStore) { ensureStartupCleanup() val started = PerfLog.nowMs() val chats = chatsWindow(40) val selfId = sessionStore.readAuthorizedUserId() - val snapshot = OfflineWarmupSnapshot( - chats = chats, - folders = folders(), - self = selfId?.let { sessionStore.readProfile(it.value) }, - mainCount = mainListCount(), - ) + val snapshot = + OfflineWarmupSnapshot( + chats = chats, + folders = folders(), + self = selfId?.let { sessionStore.readProfile(it.value) }, + mainCount = mainListCount(), + ) 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,20 +143,28 @@ open class OfflineWarmup( } } - open suspend fun chatsAfter(cursor: ChatCacheCursor, limit: Int): List = roomIo { - db?.chatDao()?.afterCursor( - cursor.archived, - cursor.pinned, - cursor.pinnedOrder, - cursor.lastMessageDate, - cursor.id, - limit - )?.map { it.toModel() }.orEmpty() - } - - open suspend fun oldestListed(archived: Boolean): Chat? = roomIo { - db?.chatDao()?.oldestListed(archived)?.toModel() - } + open suspend fun chatsAfter( + cursor: ChatCacheCursor, + limit: Int, + ): List = + roomIo { + db + ?.chatDao() + ?.afterCursor( + cursor.archived, + cursor.pinned, + cursor.pinnedOrder, + cursor.lastMessageDate, + cursor.id, + limit, + )?.map { it.toModel() } + .orEmpty() + } + + open suspend fun oldestListed(archived: Boolean): Chat? = + roomIo { + db?.chatDao()?.oldestListed(archived)?.toModel() + } /** Callers use this to skip redundant dispatcher hops for in-memory cache adapters. */ open val usesIoDispatcher: Boolean get() = db != null @@ -209,28 +217,31 @@ 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,79 +288,153 @@ 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 { - db?.let { readCloudDraftState(it, chatId, threadId) } ?: CloudDraftState( - CloudDraft( - chatId, threadId + open suspend fun cloudDraftState( + chatId: PeerId, + threadId: Int = 0, + ): CloudDraftState = + roomIo { + db?.let { readCloudDraftState(it, chatId, threadId) } ?: CloudDraftState( + CloudDraft( + 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() } + 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) } + open suspend fun cloudDraftStates(): List = + roomIo { + 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 = + open suspend fun discardLocalCloudDraft( + chatId: PeerId, + threadId: Int = 0, + ): CloudDraftState = editLocalCloudDraft(chatId, threadId, discardUnsupported = true) { CloudDraft( - chatId, threadId + chatId, + threadId, ) } private suspend fun editLocalCloudDraft( - 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 - ) - } - database.withTransaction { - val current = readCloudDraftState(database, chatId, threadId) - val state = current.localEdit(edit(current.draft), discardUnsupported, markdown) - writeCloudDraftState(database, state) - state + 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, + ) + } + database.withTransaction { + val current = readCloudDraftState(database, chatId, threadId) + val state = current.localEdit(edit(current.draft), discardUnsupported, markdown) + writeCloudDraftState(database, state) + state + } } } - } - 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 +444,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 +458,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,32 +474,51 @@ 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( 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 { - db?.metaDao()?.get("discussion_read:${chatId.value}:$topId")?.value?.toIntOrNull() ?: 0 - } + 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( - database.metaDao() - .get("discussion_read:${chatId.value}:$topId")?.value?.toIntOrNull() ?: 0, - maxId, - ) + val confirmed = + maxOf( + database + .metaDao() + .get("discussion_read:${chatId.value}:$topId") + ?.value + ?.toIntOrNull() ?: 0, + maxId, + ) database.metaDao().upsert( MetaEntity( "discussion_read:${chatId.value}:$topId", @@ -418,43 +528,66 @@ 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) } - open suspend fun applyMessageEdit(message: Message) = roomIo { - recentHistory.invalidate(message.id.chatId.value) - val database = db ?: return@roomIo - database.withTransaction { - if (mergeMessages(listOf(message)).isNotEmpty()) { - refreshPreview(message.id.chatId, message.id.id) + open suspend fun applyMessageEdit(message: Message) = + roomIo { + recentHistory.invalidate(message.id.chatId.value) + val database = db ?: return@roomIo + database.withTransaction { + 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 @@ -464,38 +597,49 @@ open class OfflineWarmup( private suspend fun refreshLatestPreview(chatId: PeerId) { val database = db ?: return val chat = database.chatDao().get(chatId.value)?.toModel() ?: return - val latest = database.messageDao().latestForChat(chatId.value, 1).firstOrNull()?.toModel() + val latest = + database + .messageDao() + .latestForChat(chatId.value, 1) + .firstOrNull() + ?.toModel() database.chatDao().upsertAll( listOf( - chat.copy( - lastMessageId = latest?.id?.id ?: 0, - lastMessagePreview = latest?.chatListPreviewSource(), - lastMessageDate = latest?.date, - lastMessageOutgoing = latest?.outgoing ?: false, - lastMessageSenderName = latest?.senderName, - lastMessageMediaKind = latest?.mediaKind, - lastMediaThumbCacheKey = latest?.thumbCacheKey, - ).toEntity(), + chat + .copy( + lastMessageId = latest?.id?.id ?: 0, + lastMessagePreview = latest?.chatListPreviewSource(), + lastMessageDate = latest?.date, + lastMessageOutgoing = latest?.outgoing ?: false, + lastMessageSenderName = latest?.senderName, + lastMessageMediaKind = latest?.mediaKind, + lastMediaThumbCacheKey = latest?.thumbCacheKey, + ).toEntity(), ), ) } open suspend fun chats(): List { - val database = db ?: run { - ensureStartupCleanup() - return emptyList() - } + val database = + db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() database.chatDao().observeAll().map { it.toModel() } } } - open suspend fun chatsWindow(limit: Int, archiveLimit: Int = 3): List { - val database = db ?: run { - ensureStartupCleanup() - return chats() - } + open suspend fun chatsWindow( + limit: Int, + archiveLimit: Int = 3, + ): List { + val database = + db ?: run { + ensureStartupCleanup() + return chats() + } return DatabaseProvider.onRoom { ensureStartupCleanup() val main = database.chatDao().mainListPage(limit, 0) @@ -505,10 +649,11 @@ open class OfflineWarmup( } open suspend fun mainListCount(): Int { - val database = db ?: run { - ensureStartupCleanup() - return 0 - } + val database = + db ?: run { + ensureStartupCleanup() + return 0 + } return DatabaseProvider.onRoom { ensureStartupCleanup() database.chatDao().mainListCount() @@ -516,10 +661,11 @@ open class OfflineWarmup( } open suspend fun chat(chatId: PeerId): Chat? { - val database = db ?: run { - ensureStartupCleanup() - return chats().firstOrNull { it.id == chatId } - } + val database = + db ?: run { + ensureStartupCleanup() + return chats().firstOrNull { it.id == chatId } + } return DatabaseProvider.onRoom { ensureStartupCleanup() database.chatDao().get(chatId.value)?.toModel() @@ -527,10 +673,11 @@ open class OfflineWarmup( } open suspend fun folders(): List { - val database = db ?: run { - ensureStartupCleanup() - return emptyList() - } + val database = + db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() database.folderDao().observeAll().map { it.toModel() } @@ -550,12 +697,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 @@ -565,8 +719,11 @@ open class OfflineWarmup( return@roomIo } database.withTransaction { - val pending = database.messageDao().byIds(message.id.chatId.value, listOf(pendingId)) - .singleOrNull() + val pending = + database + .messageDao() + .byIds(message.id.chatId.value, listOf(pendingId)) + .singleOrNull() if (pendingId != message.id.id && randomId != null && randomId != 0L && pending?.randomId == randomId) { database.messageDao().delete(message.id.chatId.value, pendingId) } @@ -575,11 +732,15 @@ open class OfflineWarmup( } } - open suspend fun messages(chatId: PeerId, limit: Int = HISTORY_CACHE_WINDOW): List { - val database = db ?: run { - ensureStartupCleanup() - return emptyList() - } + open suspend fun messages( + chatId: PeerId, + limit: Int = HISTORY_CACHE_WINDOW, + ): List { + val database = + db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() val pending = database.messageDao().pendingForChat(chatId.value).map { it.toModel() } @@ -589,12 +750,16 @@ 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() - return emptyList() - } + val database = + db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() val holes = @@ -607,51 +772,67 @@ 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() - return emptyList() - } + val database = + db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() val rows = database.messageDao().olderThan(chatId.value, beforeId, limit) - if (database.metaDao() + if (database + .metaDao() .get("history_coverage:${chatId.value}") == null - ) return@onRoom emptyList() + ) { + 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() - return emptyList() - } + val database = + db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() val rows = database.messageDao().newerThan(chatId.value, afterId, limit) - if (database.metaDao() + if (database + .metaDao() .get("history_coverage:${chatId.value}") == null - ) return@onRoom emptyList() + ) { + 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,32 +846,52 @@ 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 start = when { - beforeId != null -> (rows.minOfOrNull { it.id.id } ?: 1) - afterId != null -> afterId + 1 - else -> rows.minOfOrNull { it.id.id } ?: 1 - } - val end = when { - beforeId != null -> beforeId - 1 - afterId != null -> rows.maxOfOrNull { it.id.id } ?: afterId - else -> rows.maxOfOrNull { it.id.id } ?: 0 - } + 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 + else -> rows.minOfOrNull { it.id.id } ?: 1 + } + val end = + when { + beforeId != null -> beforeId - 1 + afterId != null -> rows.maxOfOrNull { it.id.id } ?: afterId + else -> rows.maxOfOrNull { it.id.id } ?: 0 + } historySliceOf(rows, initialized, holes, start, end) } } - open suspend fun hasHistoryHole(chatId: PeerId, startId: Int, endId: Int): Boolean = roomIo { - db?.messageHoleDao() - ?.overlaps(chatId.value, startId, endId, MessageHoleEntity.SOURCE_HISTORY) ?: false - } + open suspend fun hasHistoryHole( + chatId: PeerId, + startId: Int, + endId: Int, + ): Boolean = + roomIo { + db + ?.messageHoleDao() + ?.overlaps(chatId.value, startId, endId, MessageHoleEntity.SOURCE_HISTORY) ?: false + } open suspend fun cacheHistoryPage( chatId: PeerId, @@ -708,16 +909,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(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 +922,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 +932,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) } + } } } @@ -766,36 +973,41 @@ open class OfflineWarmup( beforeDate: Int, limit: Int = HISTORY_CACHE_WINDOW, ): List { - val database = db ?: run { - ensureStartupCleanup() - return emptyList() - } + val database = + db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() - database.messageDao().atOrBeforeDate(chatId.value, beforeDate.toLong(), limit) + database + .messageDao() + .atOrBeforeDate(chatId.value, beforeDate.toLong(), limit) .map { it.toModel() } } } - 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 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 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) @@ -803,15 +1015,17 @@ open class OfflineWarmup( val stored = roomIo { db?.chatDao()?.get(profile.id.value)?.toModel() } ?: return val title = profile.title.takeIf { !isPlaceholderPeerTitle(it, profile.id.value) } ?: stored.title - val updated = stored.copy( - title = title, - isChannel = profile.kind == "channel", - isGroup = profile.kind == "group" || profile.kind == "chat", - photoCacheKey = profile.avatarCacheKey ?: stored.photoCacheKey, - peerStatus = profile.status ?: stored.peerStatus, - peerStatusAt = profile.statusAt ?: stored.peerStatusAt, - emojiStatusDocumentId = profile.emojiStatusDocumentId ?: stored.emojiStatusDocumentId, - ) + val updated = + stored.copy( + title = title, + isChannel = profile.kind == "channel", + isGroup = profile.kind == "group" || profile.kind == "chat", + photoCacheKey = profile.avatarCacheKey ?: stored.photoCacheKey, + peerStatus = profile.status ?: stored.peerStatus, + peerStatusAt = profile.statusAt ?: stored.peerStatusAt, + emojiStatusDocumentId = profile.emojiStatusDocumentId + ?: stored.emojiStatusDocumentId, + ) upsertChats(listOf(updated)) } @@ -822,26 +1036,33 @@ 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) } } } - open suspend fun replaceFolders(folders: List) = roomIo { - val database = db ?: return@roomIo - // List order is the folder order, so the index has to land in the row. - val rows = folders.mapIndexed { index, folder -> folder.toEntity(position = index) } - database.withTransaction { - database.folderDao().clear() - database.folderDao().upsertAll(rows) + open suspend fun replaceFolders(folders: List) = + roomIo { + val database = db ?: return@roomIo + // List order is the folder order, so the index has to land in the row. + val rows = folders.mapIndexed { index, folder -> folder.toEntity(position = index) } + database.withTransaction { + database.folderDao().clear() + database.folderDao().upsertAll(rows) + } } - } - 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 { @@ -851,63 +1072,89 @@ open class OfflineWarmup( } suspend fun applyMessageRead( - 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 - ) - ) - true - } + 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, + ), + ) + } + true + } - open suspend fun upsertMessages(messages: List) = withContext(RoomLane.maintenance()) { - upsertMessagesNow(messages) - } + open suspend fun upsertMessages(messages: List) = + withContext(RoomLane.maintenance()) { + upsertMessagesNow(messages) + } - private suspend fun upsertMessagesNow(messages: List) = roomIo { - messages.map { it.id.chatId.value }.distinct().forEach(recentHistory::invalidate) - if (messages.isEmpty()) return@roomIo - val database = db ?: return@roomIo - for (batch in chunkMessages(messages)) { - currentCoroutineContext().ensureActive() - database.withTransaction { mergeMessages(batch) } - kotlinx.coroutines.yield() + private suspend fun upsertMessagesNow(messages: List) = + roomIo { + messages.map { it.id.chatId.value }.distinct().forEach(recentHistory::invalidate) + if (messages.isEmpty()) return@roomIo + val database = db ?: return@roomIo + for (batch in chunkMessages(messages)) { + currentCoroutineContext().ensureActive() + database.withTransaction { mergeMessages(batch) } + kotlinx.coroutines.yield() + } + pruneExpiredCache() } - 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() @@ -917,9 +1164,12 @@ 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 { @@ -939,28 +1189,33 @@ open class OfflineWarmup( if (dropped.isEmpty()) continue recentHistory.invalidate(chat.id.value) database.messageDao().deleteAfter(chat.id.value, chat.lastMessageId) - out += HistoryPrune( - chatId = chat.id.value, - lastMessageId = chat.lastMessageId, - cachedMaxId = dropped.max(), - droppedIds = dropped, - ) + out += + HistoryPrune( + chatId = chat.id.value, + lastMessageId = chat.lastMessageId, + cachedMaxId = dropped.max(), + droppedIds = dropped, + ) } out } } /** Drops cached rows in the latest window that the server no longer returns. */ - open suspend fun pruneMissingLatest(chatId: PeerId, fetched: List) = roomIo { - recentHistory.invalidate(chatId.value) + open suspend fun pruneMissingLatest( + chatId: PeerId, + fetched: List, + ) = roomIo { + recentHistory.invalidate(chatId.value) val database = db ?: return@roomIo if (fetched.isEmpty()) return@roomIo val keep = fetched.map { it.id.id } val minId = keep.minOrNull() ?: return@roomIo - val stale = staleIdsInsideFetchedWindow( - cachedIds = database.messageDao().idsAtOrAfter(chatId.value, minId), - fetchedIds = keep, - ) + val stale = + staleIdsInsideFetchedWindow( + cachedIds = database.messageDao().idsAtOrAfter(chatId.value, minId), + fetchedIds = keep, + ) if (stale.isNotEmpty()) { database.messageDao().deleteInChat(chatId.value, stale) } @@ -974,18 +1229,66 @@ 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, - accountGeneration = accountGeneration, - acceptedOrder = acceptedOrder, - ) - database.chatDao().upsertAll(listOf(chat.toEntity())) + val chat = + (stored ?: placeholderChat(message.id.chatId)).mergeIncomingMessage( + message, + alreadyStored = merged.alreadyStored.single(), + accountGeneration = accountGeneration, + acceptedOrder = acceptedOrder, + ) + 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() + } } } @@ -1001,225 +1304,343 @@ open class OfflineWarmup( cursor: UpdateCursorEntity?, channelCursors: Map, apply: suspend () -> Unit, - ): Boolean = roomIo { - val database = db ?: return@roomIo false - database.withTransaction { - val dao = database.updateCursorDao() - if (dao.isBatchApplied(accountGeneration, batchId)) return@withTransaction true - apply() - cursor?.let { dao.upsert(it) } - for ((chatId, pts) in channelCursors) { - val key = "$KEY_CHANNEL_CURSOR_PREFIX$chatId" - val stored = dao.get(key) - if (stored == null || stored.channelPts < pts) { - dao.upsert(UpdateCursorEntity(key, 0, 0, 0, 0, channelPts = pts)) + ): Boolean = + roomIo { + val database = db ?: return@roomIo false + database.withTransaction { + val dao = database.updateCursorDao() + if (dao.isBatchApplied(accountGeneration, batchId)) return@withTransaction true + apply() + cursor?.let { dao.upsert(it) } + for ((chatId, pts) in channelCursors) { + val key = "$KEY_CHANNEL_CURSOR_PREFIX$chatId" + val stored = dao.get(key) + if (stored == null || stored.channelPts < pts) { + dao.upsert(UpdateCursorEntity(key, 0, 0, 0, 0, channelPts = pts)) + } } + dao.insertReceipt( + UpdateBatchReceiptEntity( + accountGeneration, + batchId, + System.currentTimeMillis() + ), + ) + dao.pruneReceipts(accountGeneration, batchId - RECEIPT_WINDOW) + true } - dao.insertReceipt( - UpdateBatchReceiptEntity(accountGeneration, batchId, System.currentTimeMillis()) - ) - dao.pruneReceipts(accountGeneration, batchId - RECEIPT_WINDOW) - true } - } /** Drops cached chats/history/peers/folders. Auth flags are [SessionMetadataStore.clearSession]. */ - open suspend fun outgoingSendBlockedUntil(): Long = roomIo { - db?.metaDao()?.get("outgoing_send.blocked_until_ms")?.value?.toLongOrNull() ?: 0L - } + open suspend fun outgoingSendBlockedUntil(): Long = + roomIo { + db + ?.metaDao() + ?.get("outgoing_send.blocked_until_ms") + ?.value + ?.toLongOrNull() ?: 0L + } - open suspend fun blockOutgoingSendsUntil(deadline: Long) = roomIo { - val database = db ?: return@roomIo - database.withTransaction { - val old = - database.metaDao().get("outgoing_send.blocked_until_ms")?.value?.toLongOrNull() - ?: 0L - database.metaDao().upsert( - MetaEntity( - "outgoing_send.blocked_until_ms", maxOf(old, deadline).toString() + open suspend fun blockOutgoingSendsUntil(deadline: Long) = + roomIo { + val database = db ?: return@roomIo + database.withTransaction { + val old = + database + .metaDao() + .get("outgoing_send.blocked_until_ms") + ?.value + ?.toLongOrNull() + ?: 0L + database.metaDao().upsert( + MetaEntity( + "outgoing_send.blocked_until_ms", + maxOf(old, deadline).toString(), + ), ) - ) + } } - } open fun observeOutgoingTextSends(): Flow> = db?.outgoingTextSendDao()?.observeAll() ?: flowOf(emptyList()) open suspend fun outgoingTextSends() = roomIo { db?.outgoingTextSendDao()?.all().orEmpty() } - open suspend fun captureOutgoingTextDraft( - chatId: PeerId, threadId: Int, expectedEpoch: Long - ): CloudDraftState = draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - cloudDraftState(chatId, threadId) + + 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, + ): CloudDraftState = + draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + cloudDraftState(chatId, threadId) + } + /** Persists the send; returns whether its matching draft was consumed in the same transaction. */ open suspend fun contactImportAttempt( - 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 - } + 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 + } + } open suspend fun beginContactImport( - spec: ContactSendSpec, epoch: Long, retry: Boolean - ): ContactImportAttempt = draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(epoch)) - spec.requireValid() - val key = contactImportMetaKey(spec) - check((epoch to key) !in activeContactImports) - val attempt = roomIo { - val database = checkNotNull(db) - database.withTransaction { - val previous = database.metaDao().get(key)?.value?.let(::decodeContactImport) - check( - previous == null || retry && previous.phase in setOf( - ContactImportPhase.UNCERTAIN, - ContactImportPhase.RETRY_REQUIRED, - ContactImportPhase.NOT_ADDED, - ContactImportPhase.IN_FLIGHT - ) - ) - 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 - ).also { - database.metaDao().upsert(MetaEntity(key, encodeContactImport(it))) + spec: ContactSendSpec, + epoch: Long, + retry: Boolean, + ): ContactImportAttempt = + draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + spec.requireValid() + val key = contactImportMetaKey(spec) + check((epoch to key) !in activeContactImports) + val attempt = + roomIo { + val database = checkNotNull(db) + database.withTransaction { + val previous = + database + .metaDao() + .get(key) + ?.value + ?.let(::decodeContactImport) + check( + previous == null || retry && previous.phase in + setOf( + ContactImportPhase.UNCERTAIN, + ContactImportPhase.RETRY_REQUIRED, + ContactImportPhase.NOT_ADDED, + ContactImportPhase.IN_FLIGHT, + ), + ) + 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, + ).also { + database.metaDao().upsert(MetaEntity(key, encodeContactImport(it))) + } + } } - } + activeContactImports[epoch to key] = attempt.spec.clientId + attempt } - activeContactImports[epoch to key] = attempt.spec.clientId - attempt - } open suspend fun finishContactImport( - spec: ContactSendSpec, epoch: Long, attempt: ContactImportAttempt - ): Boolean = draftWriteMutex.withLock { - val key = contactImportMetaKey(spec) - try { - if (!outgoingAccountIsCurrent(epoch)) return@withLock false - require(attempt.phase != ContactImportPhase.IN_FLIGHT) - val encoded = encodeContactImport(attempt) - decodeContactImport(encoded) - roomIo { - val database = checkNotNull(db) - database.withTransaction { - val current = database.metaDao().get(key)?.value?.let(::decodeContactImport) - check(current != null && current.spec == attempt.spec && current.phase == ContactImportPhase.IN_FLIGHT) - database.metaDao().upsert(MetaEntity(key, encoded)) + spec: ContactSendSpec, + epoch: Long, + attempt: ContactImportAttempt, + ): Boolean = + draftWriteMutex.withLock { + val key = contactImportMetaKey(spec) + try { + if (!outgoingAccountIsCurrent(epoch)) return@withLock false + require(attempt.phase != ContactImportPhase.IN_FLIGHT) + val encoded = encodeContactImport(attempt) + decodeContactImport(encoded) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + val current = + database + .metaDao() + .get(key) + ?.value + ?.let(::decodeContactImport) + check(current != null && current.spec == attempt.spec && current.phase == ContactImportPhase.IN_FLIGHT) + database.metaDao().upsert(MetaEntity(key, encoded)) + } } + true + } finally { + activeContactImports.remove(epoch to key, attempt.spec.clientId) } - true - } finally { - activeContactImports.remove(epoch to key, attempt.spec.clientId) } - } open suspend fun enqueueOutgoingTextSend( row: OutgoingTextSendEntity, expectedEpoch: Long = outgoingAccountEpoch(), draftSnapshot: CloudDraftState? = null, consumeDraft: Boolean = true, - ): Boolean = draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - roomIo { - val database = checkNotNull(db) - database.withTransaction { - val current = draftSnapshot?.let { - require(it.draft.chatId.value == row.message.chatId) - readCloudDraftState( - database, it.draft.chatId, it.draft.threadId - ).also { state -> - check(!state.draft.hasUnsupportedContent) + ): Boolean = + draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + val current = + draftSnapshot?.let { + require(it.draft.chatId.value == row.message.chatId) + readCloudDraftState( + 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 } - } - 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 - writeCloudDraftState( - database, current.localEdit( - CloudDraft(current.draft.chatId, current.draft.threadId), markdown = false + writeCloudDraftState( + database, + current.localEdit( + CloudDraft(current.draft.chatId, current.draft.threadId), + markdown = false, + ), ) - ) - true + true + } } } - } open fun observeOutgoingMediaSends() = 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 } + roomIo { + db?.outgoingMediaSendDao()?.recoverInterrupted() + Unit + } open suspend fun markOutgoingMediaSendUncertain(batchId: Long) = - roomIo { db?.outgoingMediaSendDao()?.markUncertain(batchId); Unit } + roomIo { + db?.outgoingMediaSendDao()?.markUncertain(batchId) + Unit + } open suspend fun markOutgoingMediaSendFailed(batchId: Long) = - roomIo { db?.outgoingMediaSendDao()?.markFailed(batchId); Unit } + roomIo { + db?.outgoingMediaSendDao()?.markFailed(batchId) + Unit + } - open suspend fun retryOutgoingMediaSend(batchId: Long) = roomIo { - if (db?.outgoingMediaSendDao()?.retry(batchId) == 1) outgoingSendSync.requestResume() - } + 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, - ): Boolean = draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - enqueueMediaLocked(send, draftSnapshot, consumeDraft) - } + send: OutgoingMediaSend, + expectedEpoch: Long, + draftSnapshot: CloudDraftState? = null, + consumeDraft: Boolean = true, + ): Boolean = + draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + enqueueMediaLocked(send, draftSnapshot, consumeDraft) + } private suspend fun enqueueMediaLocked( send: OutgoingMediaSend, draftSnapshot: CloudDraftState?, consumeDraft: Boolean, - ): Boolean = roomIo { - val database = checkNotNull(db) - database.withTransaction { - val current = draftSnapshot?.let { - require(it.draft.chatId.value == send.batch.chatId) - require(it.draft.threadId == (send.items.firstOrNull()?.message?.replyToTopId ?: 0)) - readCloudDraftState( - database, it.draft.chatId, it.draft.threadId - ).also { state -> check(!state.draft.hasUnsupportedContent) } - } - 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 - writeCloudDraftState( - database, current.localEdit( - CloudDraft(current.draft.chatId, current.draft.threadId), markdown = false + ): Boolean = + roomIo { + val database = checkNotNull(db) + database.withTransaction { + val current = + draftSnapshot?.let { + require(it.draft.chatId.value == send.batch.chatId) + require( + it.draft.threadId == ( + send.items + .firstOrNull() + ?.message + ?.replyToTopId ?: 0 + ), + ) + readCloudDraftState( + database, + it.draft.chatId, + it.draft.threadId, + ).also { state -> check(!state.draft.hasUnsupportedContent) } + } + 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 + } + writeCloudDraftState( + database, + current.localEdit( + CloudDraft(current.draft.chatId, current.draft.threadId), + markdown = false, + ), ) - ) - true + true + } } - } - open suspend fun recoverOutgoingMediaFiles(expectedEpoch: Long) = draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(expectedEpoch)) - cleanupOutgoingMediaFilesLocked() - } + open suspend fun recoverOutgoingMediaFiles(expectedEpoch: Long) = + draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(expectedEpoch)) + cleanupOutgoingMediaFilesLocked() + } private suspend fun cleanupOutgoingMediaFilesLocked() { if (outgoingMediaFiles == null) return - val referenced = roomIo { - checkNotNull(db).outgoingMediaSendDao().all().flatMap { it.items } - .map { it.source.name }.toSet() - } + val referenced = + roomIo { + checkNotNull(db) + .outgoingMediaSendDao() + .all() + .flatMap { it.items } + .map { it.source.name } + .toSet() + } outgoingMediaFiles.removeUnreferenced(referenced) } @@ -1230,58 +1651,75 @@ open class OfflineWarmup( expectedEpoch: Long, draftSnapshot: CloudDraftState? = null, consumeDraft: Boolean = true, - ): OutgoingMediaAdmission = draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(expectedEpoch)) - val files = checkNotNull(outgoingMediaFiles) - try { - val retained = files.retain(uploads.map { java.io.File(it.path) }, maxTotalBytes) - val items = uploads.mapIndexed { index, upload -> - val path = "local:" + files.retainedPath(retained[index]) - OutgoingMediaItemEntity( - checkNotNull(upload.message.randomId), - batch.batchId, - index, - upload.mimeType, - retained[index], - upload.message.copy( - mediaCacheKey = path, thumbCacheKey = path, fileSize = retained[index].size - ) - ) - } - val send = OutgoingMediaSend(batch, items) - val consumed = withContext(NonCancellable) { - enqueueMediaLocked( - send, draftSnapshot, consumeDraft - ) - } - OutgoingMediaAdmission(send, consumed) - } catch (error: Throwable) { - withContext(NonCancellable) { - try { - cleanupOutgoingMediaFilesLocked() - } catch (_: Exception) { - AppLog.warn("outgoing", "retained media recovery deferred") + ): OutgoingMediaAdmission = + draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(expectedEpoch)) + val files = checkNotNull(outgoingMediaFiles) + try { + val retained = files.retain(uploads.map { java.io.File(it.path) }, maxTotalBytes) + val items = + uploads.mapIndexed { index, upload -> + val path = "local:" + files.retainedPath(retained[index]) + OutgoingMediaItemEntity( + checkNotNull(upload.message.randomId), + batch.batchId, + index, + upload.mimeType, + retained[index], + upload.message.copy( + mediaCacheKey = path, + thumbCacheKey = path, + fileSize = retained[index].size, + ), + ) + } + val send = OutgoingMediaSend(batch, items) + val consumed = + withContext(NonCancellable) { + enqueueMediaLocked( + send, + draftSnapshot, + consumeDraft, + ) + } + OutgoingMediaAdmission(send, consumed) + } catch (error: Throwable) { + withContext(NonCancellable) { + try { + cleanupOutgoingMediaFilesLocked() + } catch (_: Exception) { + AppLog.warn("outgoing", "retained media recovery deferred") + } } + throw error } - throw error } - } open suspend fun claimOutgoingTextSend(randomId: Long): Boolean = roomIo { db?.outgoingTextSendDao()?.claim(randomId) == 1 } open suspend fun recoverInterruptedTextSends() = - roomIo { db?.outgoingTextSendDao()?.recoverInterrupted(); Unit } + roomIo { + db?.outgoingTextSendDao()?.recoverInterrupted() + Unit + } open suspend fun markOutgoingTextSendUncertain(randomId: Long) = - roomIo { db?.outgoingTextSendDao()?.markUncertain(randomId); Unit } + roomIo { + db?.outgoingTextSendDao()?.markUncertain(randomId) + Unit + } open suspend fun markOutgoingTextSendFailed(randomId: Long) = - roomIo { db?.outgoingTextSendDao()?.markFailed(randomId); Unit } + roomIo { + db?.outgoingTextSendDao()?.markFailed(randomId) + Unit + } - open suspend fun retryOutgoingTextSend(randomId: Long) = roomIo { - if (db?.outgoingTextSendDao()?.retry(randomId) == 1) outgoingSendSync.requestResume() - } + open suspend fun retryOutgoingTextSend(randomId: Long) = + roomIo { + if (db?.outgoingTextSendDao()?.retry(randomId) == 1) outgoingSendSync.requestResume() + } open suspend fun applyOutgoingMediaResults( batchId: Long, @@ -1296,20 +1734,29 @@ 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()) - val messageIds = results.map { (_, result) -> - val message = when (result) { - is OutgoingMessageResult.Sent -> result.message - is OutgoingMessageResult.Scheduled -> result.message.message + 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 + is OutgoingMessageResult.Scheduled -> result.message.message + } + require(message.id.chatId.value == send.batch.chatId && message.id.id > 0) + (result is OutgoingMessageResult.Scheduled) to message.id.id } - require(message.id.chatId.value == send.batch.chatId && message.id.id > 0) - (result is OutgoingMessageResult.Scheduled) to message.id.id - } 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()) @@ -1317,17 +1764,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 = @@ -1335,37 +1786,60 @@ 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) + 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() - ) + 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 - val session = LiveLocationSession( - message.id, randomId, deadline, phase - ) - database.metaDao() + if (deadline != null && deadline <= System.currentTimeMillis() / 1000) { + LiveLocationPhase.EXPIRED + } else { + LiveLocationPhase.ACTIVE + } + val session = + LiveLocationSession( + 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) + } } } } } - val chatId = when (result) { - is OutgoingMessageResult.Sent -> result.message.id.chatId.value - is OutgoingMessageResult.Scheduled -> result.message.message.id.chatId.value - } + val chatId = + when (result) { + is OutgoingMessageResult.Sent -> result.message.id.chatId.value + is OutgoingMessageResult.Scheduled -> result.message.message.id.chatId.value + } database.outgoingTextSendDao().acknowledge(randomId, chatId) } } @@ -1380,93 +1854,121 @@ open class OfflineWarmup( } ?: flowOf(emptyList()) open suspend fun liveLocation( - id: MessageId, epoch: Long - ): LiveLocationSession? = draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(epoch)) - roomIo { - db?.metaDao()?.get(liveLocationKey(id))?.let { - decodeLiveLocation(it.value).also { session -> - readStoredPayload { - require(session.messageId == id) + id: MessageId, + epoch: Long, + ): LiveLocationSession? = + draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + roomIo { + db?.metaDao()?.get(liveLocationKey(id))?.let { + decodeLiveLocation(it.value).also { session -> + readStoredPayload { + require(session.messageId == id) + } } } } } - } open suspend fun transitionLiveLocation( - id: MessageId, revision: Long, phase: LiveLocationPhase, epoch: Long - ): Boolean = draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(epoch)) - roomIo { - val database = checkNotNull(db) - database.withTransaction { - val key = liveLocationKey(id) - val current = database.metaDao().get(key)?.let { decodeLiveLocation(it.value) } - ?: return@withTransaction false - require(current.messageId == id) - if (current.revision != revision || current.revision == Long.MAX_VALUE || !current.canTransition( - phase - ) || (phase == LiveLocationPhase.ACTIVE && current.expired( - System.currentTimeMillis() / 1000 - )) - ) return@withTransaction false - database.metaDao().upsert( - MetaEntity( - key, - encodeLiveLocation(current.copy(phase = phase, revision = revision + 1)) - ) - ) - true - } - } - } - - open suspend fun recoverLiveLocations(epoch: Long) = draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(epoch)) - roomIo { - val database = checkNotNull(db) - database.withTransaction { - for (key in database.metaDao().keysLike("live_location:%")) { + id: MessageId, + revision: Long, + phase: LiveLocationPhase, + epoch: Long, + ): Boolean = + draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + val key = liveLocationKey(id) val current = - decodeLiveLocation(checkNotNull(database.metaDao().get(key)).value) - require(key == liveLocationKey(current.messageId)) - val phase = when (current.phase) { - LiveLocationPhase.ACTIVE -> LiveLocationPhase.PAUSED - LiveLocationPhase.STOPPING -> LiveLocationPhase.UNCERTAIN_STOP - else -> continue + database.metaDao().get(key)?.let { decodeLiveLocation(it.value) } + ?: return@withTransaction false + require(current.messageId == id) + if (current.revision != revision || current.revision == Long.MAX_VALUE || + !current.canTransition( + phase, + ) || ( + phase == LiveLocationPhase.ACTIVE && + current.expired( + System.currentTimeMillis() / 1000, + ) + ) + ) { + return@withTransaction false } - 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 = revision + 1 ) - ) - ) + ), + ), ) + true } } } - } - open suspend fun blockScheduledQueueUntil(deadline: Long, expectedEpoch: Long) = + open suspend fun recoverLiveLocations(epoch: Long) = draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - blockOutgoingSendsUntil(deadline) + check(outgoingAccountIsCurrent(epoch)) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + for (key in database.metaDao().keysLike("live_location:%")) { + val current = + decodeLiveLocation(checkNotNull(database.metaDao().get(key)).value) + require(key == liveLocationKey(current.messageId)) + val phase = + when (current.phase) { + LiveLocationPhase.ACTIVE -> LiveLocationPhase.PAUSED + LiveLocationPhase.STOPPING -> LiveLocationPhase.UNCERTAIN_STOP + else -> continue + } + check(current.revision < Long.MAX_VALUE) + database.metaDao().upsert( + MetaEntity( + key, + encodeLiveLocation( + current.copy( + phase = phase, + revision = current.revision + 1, + ), + ), + ), + ) + } + } + } } + 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 = + open suspend fun scheduledQueueRevision( + chatId: PeerId, + expectedEpoch: Long, + ): Long = draftWriteMutex.withLock { check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) roomIo { checkNotNull(db).scheduledMessageDao().revision(chatId.value) } @@ -1477,83 +1979,101 @@ open class OfflineWarmup( expectedRevision: Long, messages: List, expectedEpoch: Long, - ): Boolean = draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - roomIo { - checkNotNull(db).scheduledMessageDao().replaceIfCurrent( - chatId.value, expectedRevision, messages.map { - ScheduledMessageEntity( - it.message.toEntity(), it.hasUnsupportedContent - ) - }) + ): Boolean = + draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + roomIo { + checkNotNull(db).scheduledMessageDao().replaceIfCurrent( + chatId.value, + expectedRevision, + messages.map { + ScheduledMessageEntity( + it.message.toEntity(), + it.hasUnsupportedContent, + ) + }, + ) + } } - } - open suspend fun applyScheduledMessage(message: ScheduledMessage) = roomIo { - db?.scheduledMessageDao()?.applyMessages( - message.message.id.chatId.value, listOf( - ScheduledMessageEntity( - message.message.toEntity(), message.hasUnsupportedContent - ) + open suspend fun applyScheduledMessage(message: ScheduledMessage) = + roomIo { + db?.scheduledMessageDao()?.applyMessages( + message.message.id.chatId.value, + listOf( + ScheduledMessageEntity( + message.message.toEntity(), + message.hasUnsupportedContent, + ), + ), ) - ) - Unit - } + 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 } - open suspend fun clearAccountCache() = draftWriteMutex.withLock { - outgoingAccountActive = false - outgoingAccountEpoch.incrementAndGet() - draftSync.reset() - outgoingSendSync.reset() - roomIo { - recentWarmupJob?.cancel() - recentHistory.clear() - firstPaintSnapshot = null - recentWarmupChats = emptyList() - val database = db ?: return@roomIo - database.messageDao().clearAll() - database.scheduledMessageDao().clear() - database.outgoingTextSendDao().clear() - database.outgoingMediaSendDao().clear() - database.messageTombstoneDao().clear() - database.chatDao().clear() - database.folderDao().clear() - database.peerDao().clear() - database.updateCursorDao().clear() - database.updateCursorDao().clearReceipts() - database.profileTabsDao().clear() - database.profileMembersDao().clear() - database.profileMediaDao().clear() - database.profileCommonDao().clear() - database.messageHoleDao().clearAll() - database.metaDao().deletePrefix("history_coverage:") - database.metaDao().deleteLike("discussion_read:%") - database.metaDao().deleteLike("${DRAFT_META_PREFIX}%") - database.metaDao().deleteLike("cloud_draft:%") - database.metaDao().deleteLike("outgoing_send.%") - database.metaDao().deleteLike("contact_import:%") - database.metaDao().deleteLike("live_location:%") - activeContactImports.clear() - } - try { - outgoingMediaFiles?.removeUnreferenced(emptySet()) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - AppLog.warn("outgoing", "account media cleanup deferred") + open suspend fun clearAccountCache() = + draftWriteMutex.withLock { + outgoingAccountActive = false + outgoingAccountEpoch.incrementAndGet() + draftSync.reset() + outgoingSendSync.reset() + roomIo { + recentWarmupJob?.cancel() + recentHistory.clear() + firstPaintSnapshot = null + recentWarmupChats = emptyList() + val database = db ?: return@roomIo + database.messageDao().clearAll() + database.scheduledMessageDao().clear() + database.outgoingTextSendDao().clear() + database.outgoingMediaSendDao().clear() + database.messageTombstoneDao().clear() + database.chatDao().clear() + database.folderDao().clear() + database.peerDao().clear() + database.updateCursorDao().clear() + database.updateCursorDao().clearReceipts() + database.profileTabsDao().clear() + database.profileMembersDao().clear() + database.profileMediaDao().clear() + database.profileCommonDao().clear() + database.messageHoleDao().clearAll() + database.metaDao().deletePrefix("history_coverage:") + database.metaDao().deleteLike("discussion_read:%") + database.metaDao().deleteLike("${DRAFT_META_PREFIX}%") + database.metaDao().deleteLike("cloud_draft:%") + database.metaDao().deleteLike("outgoing_send.%") + database.metaDao().deleteLike("contact_import:%") + database.metaDao().deleteLike("live_location:%") + activeContactImports.clear() + } + try { + outgoingMediaFiles?.removeUnreferenced(emptySet()) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("outgoing", "account media cleanup deferred") + } } - } - 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 @@ -1563,7 +2083,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) { @@ -1575,11 +2099,12 @@ open class OfflineWarmup( } /** Users are positive; groups/channels are negative. Unknown non-user peers start unjoined. */ -internal fun placeholderChat(chatId: PeerId) = Chat( - id = chatId, - title = "", - left = chatId.value < 0, -) +internal fun placeholderChat(chatId: PeerId) = + Chat( + id = chatId, + title = "", + left = chatId.value < 0, + ) data class HistoryPrune( val chatId: Long, @@ -1591,14 +2116,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() @@ -1616,6 +2149,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 } @@ -1657,5 +2191,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/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/MetaDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/MetaDao.kt index c91f0c28c..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 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/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..abdf3239d 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,9 +39,11 @@ 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', '_', '-', ',', '.') - .mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() } } + val letters = + title + .trim() + .split(' ', '\t', '\n', '_', '-', ',', '.') + .mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() } } return when { letters.size >= 2 -> "${letters[0].uppercaseChar()}${letters[1].uppercaseChar()}" letters.size == 1 -> letters[0].uppercaseChar().toString() @@ -50,10 +60,11 @@ fun ServiceAvatar( size: Dp = 52.dp, ) { Box( - modifier = modifier - .size(size) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), + modifier = + modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center, ) { Icon( @@ -65,6 +76,40 @@ 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,66 +117,88 @@ 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) - .clip(CircleShape) - .background(avatarBg.copy(alpha = 0.22f)), + modifier = + modifier + .size(size) + .clip(CircleShape) + .background(avatarBg.copy(alpha = 0.22f)), contentAlignment = Alignment.Center, ) { Text( text = initial, - style = MaterialTheme.typography.titleMedium.copy( - fontSize = if (size >= 80.dp) 34.sp else (size.value * 0.36f).sp, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ), + style = + MaterialTheme.typography.titleMedium.copy( + fontSize = if (size >= 80.dp) 34.sp else (size.value * 0.36f).sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + ), 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) { if (webmFile(file) || mp4File(file)) { LoopingVideo( file = file, - modifier = Modifier - .fillMaxSize() - .clip(CircleShape), + modifier = + Modifier + .fillMaxSize() + .clip(CircleShape), crop = true, ) } 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()}") + .diskCacheKey("${file.absolutePath}:${file.length()}") + .crossfade(false) + .build(), contentDescription = null, - modifier = Modifier - .fillMaxSize() - .clip(CircleShape), + modifier = + Modifier + .fillMaxSize() + .clip(CircleShape), contentScale = ContentScale.Crop, ) } } else if (preview != null) { val context = LocalContext.current AsyncImage( - model = ImageRequest.Builder(context) - .data(preview) - .memoryCacheKey("peer-stripped:${preview.size}:${preview.contentHashCode()}") - .diskCachePolicy(CachePolicy.DISABLED) - .crossfade(false) - .size(128) - .transformations(PreviewBlurTransformation()) - .build(), + model = + ImageRequest + .Builder(context) + .data(preview) + .memoryCacheKey("peer-stripped:${preview.size}:${preview.contentHashCode()}") + .diskCachePolicy(CachePolicy.DISABLED) + .crossfade(false) + .size(128) + .transformations(PreviewBlurTransformation()) + .build(), contentDescription = null, - modifier = Modifier - .fillMaxSize() - .clip(CircleShape), + modifier = + Modifier + .fillMaxSize() + .clip(CircleShape), contentScale = ContentScale.Crop, ) } 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/ChatsExecutor.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsExecutor.kt index cd9b83423..2949b6b66 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)) } @@ -106,6 +108,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 @@ -144,7 +147,7 @@ internal class ChatsExecutor( .onEach { update -> when (update) { is MtprotoUpdate.SyncState -> { - protocolSyncing = update.isSyncing + protocolSyncing.value = update.isSyncing publishSyncing() } @@ -268,7 +271,10 @@ 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 @@ -464,10 +470,17 @@ internal class ChatsExecutor( 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. @@ -624,6 +637,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 @@ -692,8 +706,11 @@ 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") + var result = coroutineScope { + val dialogs = async { getChatsTimed("get") } + if (!notifyDefaultsLoaded) loadNotifyDefaults() + dialogs.await() + } if (result is Outcome.Err && state().chats.isNotEmpty() && !result.telegramError.requiresReauth @@ -773,9 +790,11 @@ internal class ChatsExecutor( ) } } + 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 +805,16 @@ 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) { @@ -1395,12 +1422,16 @@ 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 @@ -1417,11 +1448,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 } 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 d3369f589..fe22553b7 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) { @@ -397,6 +400,21 @@ 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 && @@ -436,7 +454,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 = { 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/store/DialogExecutor.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt index 58e7213e2..648a58d3a 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 @@ -202,7 +202,7 @@ internal class DialogExecutor( if (deleted.isNotEmpty()) { dispatch(Msg.MessagesDeleted(deleted)) rememberPinned() - scope.launch { + if (!update.persisted) scope.launch { warmup?.deleteMessages(chatId, deleted) } } @@ -903,6 +903,7 @@ 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 @@ -917,6 +918,7 @@ internal class DialogExecutor( dispatch(Msg.Loading(false)) loadPinned() loadChatProfile() + completeInitialHistoryNavigation() return@launch } AppLog.api( @@ -982,13 +984,17 @@ 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() } } 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..d3b08d10c 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 @@ -322,12 +322,12 @@ 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) ) { + slice.rows.minOfOrNull { it.id.id }?.let { serverHistoryBoundaryId = it } emit(Msg.HasOlder(!slice.confirmedStart)) emit(Msg.LoadingOlder(false)) activeOlderRequestId = null 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/MessageBubble.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageBubble.kt index 99307bbc8..7bf524615 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 @@ -89,6 +84,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 +98,7 @@ import java.util.Locale @OptIn( ExperimentalFoundationApi::class, - androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class + androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class, ) @Composable fun MessageBubble( @@ -157,19 +155,21 @@ fun MessageBubble( contentAlignment = Alignment.Center, ) { Text( - text = localizedServiceMessage( - raw = message.text.orEmpty(), - outgoing = outgoing, - ), - modifier = Modifier - .then( - if (onQuoteClick != null && message.replyToMsgId != null) { - Modifier.clickable(onClick = onQuoteClick) - } else { - Modifier - }, - ) - .padding(horizontal = 24.dp, vertical = 8.dp), + text = + localizedServiceMessage( + raw = message.text.orEmpty(), + outgoing = outgoing, + ), + modifier = + Modifier + .then( + if (onQuoteClick != null && message.replyToMsgId != null) { + Modifier.clickable(onClick = onQuoteClick) + } else { + Modifier + }, + ) + .padding(horizontal = 24.dp, vertical = 8.dp), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -182,9 +182,10 @@ fun MessageBubble( var appearancePlayed by rememberSaveable(appearanceKey) { mutableStateOf(false) } - val appear = remember(appearanceKey) { - Animatable(if (!appearancePlayed && message.shouldPlayAppear()) 0f else 1f) - } + val appear = + remember(appearanceKey) { + Animatable(if (!appearancePlayed && message.shouldPlayAppear()) 0f else 1f) + } LaunchedEffect(appearanceKey) { appearancePlayed = true if (appear.value < 1f) { @@ -199,20 +200,23 @@ fun MessageBubble( val hasSpoiler = renderedBlocks.containsSpoilers() val senderTitle = sender?.title?.takeIf { it.isNotBlank() } ?: message.senderName?.takeIf { it.isNotBlank() } - val quote = if (quoted?.mediaKind == "service") { - // Service payloads are deliberately compact on the wire; never expose - // their unit separators in a reply preview. - localizedServiceMessage( - raw = quoted.text.orEmpty(), - outgoing = quoted.outgoing, - ) - } else { - message.replyQuote?.takeIf { it.isNotBlank() } ?: quoted?.text?.takeIf { it.isNotBlank() } - } + val quote = + if (quoted?.mediaKind == "service") { + // Service payloads are deliberately compact on the wire; never expose + // their unit separators in a reply preview. + localizedServiceMessage( + raw = quoted.text.orEmpty(), + outgoing = quoted.outgoing, + ) + } else { + message.replyQuote?.takeIf { it.isNotBlank() } + ?: quoted?.text?.takeIf { it.isNotBlank() } + } val showReplyQuote = !hideTopicRootReply && (message.replyToMsgId != null || !quote.isNullOrBlank()) val bubbleHeader = - (showSender && senderTitle != null) || !message.fwdFrom.isNullOrBlank() || message.fwdFromId != null || message.fwdDate != null || !message.viaBot.isNullOrBlank() + (showSender && senderTitle != null) || !message.fwdFrom.isNullOrBlank() || message.fwdFromId != null || message.fwdDate != null || + !message.viaBot.isNullOrBlank() val mediaCaption = shouldShowMessageCaption(message.mediaKind, message.text, message.fileName) val isVisual = isVisualAlbum(album) val isNonVisual = isNonVisualAlbum(album) @@ -227,162 +231,175 @@ fun MessageBubble( val edgeMedia = edgeVisualMedia && !mediaOnlyBubble val mediaBleedTop = edgeMedia && !bubbleHeader && !showReplyQuote val mediaBleedBottom = shouldBleedMediaBottom(edgeMedia, mediaCaption, hasComments) - val mediaOverlayMeta = shouldOverlayMediaMeta( - stickerOnly = stickerOnly, - edgeVisualMedia = edgeVisualMedia, - mediaCaption = mediaCaption, - ) + val mediaOverlayMeta = + shouldOverlayMediaMeta( + stickerOnly = stickerOnly, + edgeVisualMedia = edgeVisualMedia, + mediaCaption = mediaCaption, + ) // Picture bubbles let the media own the horizontal edges, so the text around it is // padded on its own instead of the whole bubble. val edgeContentPad = if (edgeVisualMedia) BUBBLE_CONTENT_PAD else 0.dp // A caption never widens a picture bubble: the bubble hugs the picture and the // caption wraps, so the picture stays flush with both edges. - val mediaFrameWidth = if (singleVisualMedia) { - bubbleEdgeMediaDisplaySize( - message.mediaKind, - message.mediaWidth, - message.mediaHeight - ).first.dp - } else { - null - } - val shape = remember(outgoing, joinsMessageAbove, joinsMessageBelow) { - messageShape(outgoing, joinsMessageAbove, joinsMessageBelow) - } - val container = if (outgoing) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - } - val onContainer = if (outgoing) { - MaterialTheme.colorScheme.onPrimaryContainer - } 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 - } - }, - ) + val mediaFrameWidth = + if (singleVisualMedia) { + bubbleEdgeMediaDisplaySize( + message.mediaKind, + message.mediaWidth, + message.mediaHeight, + ).first.dp + } else { + null + } + val shape = + remember(outgoing, joinsMessageAbove, joinsMessageBelow) { + messageShape(outgoing, joinsMessageAbove, joinsMessageBelow) + } + val container = + if (outgoing) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val onContainer = + if (outgoing) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } 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) } + ?: 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 + } + }, + ) PeerAvatar( title = senderTitle.orEmpty(), size = 28.dp, imageFile = senderAvatar, - previewBytes = rememberStrippedPreview(sender?.id) { - sender?.id?.let { mediaRepository?.peerInlineThumbJpeg(it) } - }, - modifier = Modifier - .padding(end = 6.dp, bottom = 2.dp) - .then( - if (onOpenSender != null) { - Modifier.clickable(onClick = onOpenSender) - } else { - Modifier - }, - ), + previewBytes = + rememberStrippedPreview(sender?.id) { + sender?.id?.let { mediaRepository?.peerInlineThumbJpeg(it) } + }, + modifier = + Modifier + .padding(end = 6.dp, bottom = 2.dp) + .then( + if (onOpenSender != null) { + Modifier.clickable(onClick = onOpenSender) + } else { + Modifier + }, + ), ) } else if (!outgoing && reserveAvatarGutter) { Spacer(Modifier.size(width = 34.dp, height = 28.dp)) } - val centerMedia = !stickerOnly && when (message.mediaKind) { - "photo", "video", "gif" -> true - else -> false - } + val centerMedia = + !stickerOnly && + when (message.mediaKind) { + "photo", "video", "gif" -> true + else -> false + } Column( - modifier = Modifier - .widthIn(max = BUBBLE_MAX_WIDTH_DP.dp) - .wrapContentWidth( - align = if (outgoing) Alignment.End else Alignment.Start, - ) - .width(IntrinsicSize.Max) - .graphicsLayer { - val t = appear.value - translationY = (1f - t) * (if (outgoing) 6f else 10f) - }, + modifier = + Modifier + .widthIn(max = BUBBLE_MAX_WIDTH_DP.dp) + .wrapContentWidth( + align = if (outgoing) Alignment.End else Alignment.Start, + ) + .width(IntrinsicSize.Max) + .graphicsLayer { + val t = appear.value + translationY = (1f - t) * (if (outgoing) 6f else 10f) + }, horizontalAlignment = if (outgoing) Alignment.End else Alignment.Start, ) { Column( - modifier = Modifier - .then(if (stickerOnly) Modifier else Modifier.clip(shape)) - .then(mediaFrameWidth?.let { Modifier.width(it) } ?: Modifier) - .then( - if (stickerOnly || mediaOnlyBubble) { - Modifier - } else { - Modifier.background(container) - }, - ) - .then( - if (!selectable && !passClicks && onOpenMenu != null) { - val openMenu = rememberUpdatedState(onOpenMenu) - val hold = rememberUpdatedState(onLongPress) - Modifier - .onGloballyPositioned { bubblePosition = it.positionInWindow() } - .pointerInput(hasSpoiler) { - if (hasSpoiler) { - menuOrHold( - onTap = { - spoilersVisible = !spoilersVisible - openMenu.value.invoke(bubblePosition) - }, - onLongPress = { - hold.value?.invoke() ?: openMenu.value.invoke( - bubblePosition - ) - }, - ) - } else { - menuOrHold( - onTap = { openMenu.value.invoke(bubblePosition + it) }, - onLongPress = { - hold.value?.invoke() ?: openMenu.value.invoke( - bubblePosition - ) - }, - ) - } - } - } else if (!selectable && !passClicks && (onLongPress != null || hasSpoiler)) { - Modifier.combinedClickable( - onClick = { - if (hasSpoiler) { - spoilersVisible = !spoilersVisible + modifier = + Modifier + .then(if (stickerOnly) Modifier else Modifier.clip(shape)) + .then(mediaFrameWidth?.let { Modifier.width(it) } ?: Modifier) + .then( + if (stickerOnly || mediaOnlyBubble) { + Modifier + } else { + Modifier.background(container) + }, + ) + .then( + if (!selectable && !passClicks && onOpenMenu != null) { + val openMenu = rememberUpdatedState(onOpenMenu) + val hold = rememberUpdatedState(onLongPress) + Modifier + .onGloballyPositioned { bubblePosition = it.positionInWindow() } + .pointerInput(hasSpoiler) { + if (hasSpoiler) { + menuOrHold( + onTap = { + spoilersVisible = !spoilersVisible + openMenu.value.invoke(bubblePosition) + }, + onLongPress = { + hold.value?.invoke() ?: openMenu.value.invoke( + bubblePosition, + ) + }, + ) + } else { + menuOrHold( + onTap = { openMenu.value.invoke(bubblePosition + it) }, + onLongPress = { + hold.value?.invoke() ?: openMenu.value.invoke( + bubblePosition, + ) + }, + ) + } } + } else if (!selectable && !passClicks && (onLongPress != null || hasSpoiler)) { + Modifier.combinedClickable( + onClick = { + if (hasSpoiler) { + spoilersVisible = !spoilersVisible + } + }, + onLongClick = { onLongPress?.invoke() }, + onClickLabel = null, + ) + } else { + Modifier + }, + ) + .padding( + horizontal = if (stickerOnly || edgeVisualMedia) 0.dp else BUBBLE_CONTENT_PAD, + vertical = + when { + stickerOnly -> 2.dp + mediaOnlyBubble -> 0.dp + else -> BUBBLE_CONTENT_VPAD }, - onLongClick = { onLongPress?.invoke() }, - onClickLabel = null, - ) - } else { - Modifier - }, - ) - .padding( - horizontal = if (stickerOnly || edgeVisualMedia) 0.dp else BUBBLE_CONTENT_PAD, - vertical = when { - stickerOnly -> 2.dp - mediaOnlyBubble -> 0.dp - else -> BUBBLE_CONTENT_VPAD - }, - ), + ), verticalArrangement = Arrangement.spacedBy(if (showSender) 1.dp else 4.dp), ) { if (showSender && senderTitle != null) { @@ -397,27 +414,30 @@ fun MessageBubble( color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f, fill = false) - .then( - if (onOpenSender != null) { - Modifier.clickable(onClick = onOpenSender) - } else { - Modifier - }, - ), + modifier = + Modifier + .weight(1f, fill = false) + .then( + if (onOpenSender != null) { + Modifier.clickable(onClick = onOpenSender) + } else { + Modifier + }, + ), ) sender?.emojiStatusDocumentId?.let { documentId -> CustomEmojiGlyph( documentId = documentId, size = 16.dp, - onClick = onOpenStickerPack?.let { open -> - { - open( - documentId, true - ) - } - }, + onClick = + onOpenStickerPack?.let { open -> + { + open( + documentId, + true, + ) + } + }, ) } senderTagLabel(senderTag)?.let { tag -> @@ -427,31 +447,36 @@ fun MessageBubble( color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier - .clip(MaterialTheme.shapes.extraSmall) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)) - .padding(horizontal = 6.dp, vertical = 1.dp), + modifier = + Modifier + .clip(MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)) + .padding(horizontal = 6.dp, vertical = 1.dp), ) } } } if (!message.fwdFrom.isNullOrBlank() || message.fwdFromId != null || message.fwdDate != null) { Column( - modifier = Modifier - .then( - if (onOpenForwardSource != null) { - Modifier - .heightIn(min = 48.dp) - .clickable(onClick = onOpenForwardSource) - } else Modifier, - ) - .padding(vertical = 3.dp, horizontal = edgeContentPad), + modifier = + Modifier + .then( + if (onOpenForwardSource != null) { + Modifier + .heightIn(min = 48.dp) + .clickable(onClick = onOpenForwardSource) + } else { + Modifier + }, + ) + .padding(vertical = 3.dp, horizontal = edgeContentPad), verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - text = message.fwdFrom?.takeIf { it.isNotBlank() }?.let { - stringResource(R.string.dialog_forwarded_from, it) - } ?: stringResource(R.string.dialog_forwarded_message), + text = + message.fwdFrom?.takeIf { it.isNotBlank() }?.let { + stringResource(R.string.dialog_forwarded_from, it) + } ?: stringResource(R.string.dialog_forwarded_message), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, maxLines = 2, @@ -464,7 +489,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( @@ -506,22 +531,24 @@ fun MessageBubble( messages = album, mediaRepository = mediaRepository, shape = shape, - onLongPress = onLongPress ?: onOpenMenu?.let { open -> - { - open( - bubblePosition + onLongPress = + onLongPress ?: onOpenMenu?.let { open -> + { + open( + bubblePosition, + ) + } + }, + modifier = + if (mediaOnlyBubble) { + Modifier + } else { + Modifier.bleedBubbleMedia( + horizontal = 0.dp, + bleedTop = mediaBleedTop, + bleedBottom = mediaBleedBottom, ) - } - }, - modifier = if (mediaOnlyBubble) { - Modifier - } else { - Modifier.bleedBubbleMedia( - horizontal = 0.dp, - bleedTop = mediaBleedTop, - bleedBottom = mediaBleedBottom, - ) - }, + }, ) } else if (isNonVisual) { Column( @@ -533,21 +560,23 @@ fun MessageBubble( message = docMessage, mediaRepository = mediaRepository, edgeToEdge = false, - onStickerClick = onOpenStickerPack?.let { open -> - { - stickerDocumentId(docMessage.mediaCacheKey)?.let { id -> - open(id, false) + onStickerClick = + onOpenStickerPack?.let { open -> + { + stickerDocumentId(docMessage.mediaCacheKey)?.let { id -> + open(id, false) + } } - } - }, + }, onInstantView = onInstantView, - onLongPress = onLongPress ?: onOpenMenu?.let { open -> - { - open( - bubblePosition - ) - } - }, + onLongPress = + onLongPress ?: onOpenMenu?.let { open -> + { + open( + bubblePosition, + ) + } + }, modifier = Modifier.fillMaxWidth(), ) } @@ -558,43 +587,48 @@ fun MessageBubble( mediaRepository = mediaRepository, onClosePoll = onClosePoll?.let { close -> { close(message.id.id) } }, pollVoting = pollVoting, - onPollVote = onPollVote?.let { vote -> - { options -> - vote( - message.id.id, options - ) - } - }, + onPollVote = + onPollVote?.let { vote -> + { options -> + vote( + message.id.id, + options, + ) + } + }, onShowPollVoters = onShowPollVoters, ) MessageMedia( message = message, mediaRepository = mediaRepository, edgeToEdge = singleVisualMedia, - onStickerClick = onOpenStickerPack?.let { open -> - { - stickerDocumentId(message.mediaCacheKey)?.let { id -> - open(id, false) + onStickerClick = + onOpenStickerPack?.let { open -> + { + stickerDocumentId(message.mediaCacheKey)?.let { id -> + open(id, false) + } } - } - }, + }, onInstantView = onInstantView, - onLongPress = onLongPress ?: onOpenMenu?.let { open -> - { - open( - bubblePosition + onLongPress = + onLongPress ?: onOpenMenu?.let { open -> + { + open( + bubblePosition, + ) + } + }, + modifier = + if (singleVisualMedia) { + Modifier.bleedBubbleMedia( + horizontal = 0.dp, + bleedTop = mediaBleedTop, + bleedBottom = mediaBleedBottom, ) - } - }, - modifier = if (singleVisualMedia) { - Modifier.bleedBubbleMedia( - horizontal = 0.dp, - bleedTop = mediaBleedTop, - bleedBottom = mediaBleedBottom, - ) - } else { - Modifier - }, + } else { + Modifier + }, ) } if (mediaOverlayMeta) { @@ -603,21 +637,23 @@ fun MessageBubble( time = messageTime(message.date), color = Color.White, showReadStatus = showReadStatus, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(8.dp) - .clip(RoundedCornerShape(6.dp)) - .background(Color.Black.copy(alpha = 0.5f)) - .padding(horizontal = 6.dp, vertical = 2.dp), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color.Black.copy(alpha = 0.5f)) + .padding(horizontal = 6.dp, vertical = 2.dp), ) } } message.checklist?.let { list -> Column( - modifier = Modifier - .fillMaxWidthInBubble() - .padding(horizontal = 12.dp, vertical = 8.dp) - .animateContentSize(), + modifier = + Modifier + .fillMaxWidthInBubble() + .padding(horizontal = 12.dp, vertical = 8.dp) + .animateContentSize(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { if (list.title.isNotBlank()) { @@ -642,38 +678,47 @@ 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 }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), + modifier = + Modifier + .fillMaxWidth() + .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 } + 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) + onToggleChecklist != null && !checklistBusy && !message.pending && message.id.id > 0 && + (message.outgoing || list.othersCanComplete) Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .toggleable( - value = item.done, - enabled = enabled, - role = androidx.compose.ui.semantics.Role.Checkbox, - onValueChange = { onToggleChecklist?.invoke(item.id) }), + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .toggleable( + value = item.done, + enabled = enabled, + role = androidx.compose.ui.semantics.Role.Checkbox, + onValueChange = { onToggleChecklist?.invoke(item.id) }, + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -685,9 +730,10 @@ fun MessageBubble( modifier = Modifier.weight(1f), text = item.text, entities = item.entities, - textStyle = MaterialTheme.typography.bodyMedium.copy( - textDecoration = if (item.done) androidx.compose.ui.text.style.TextDecoration.LineThrough else null - ), + textStyle = + MaterialTheme.typography.bodyMedium.copy( + textDecoration = if (item.done) androidx.compose.ui.text.style.TextDecoration.LineThrough else null, + ), contentColor = itemColor, linkColor = MaterialTheme.colorScheme.primary, revealSpoilers = spoilersVisible, @@ -701,7 +747,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,101 +755,119 @@ 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 - ) + contentDescription = + stringResource( + 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)) + } + }, + ) + } } } } if (addEnabled) { Row( - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.large) - .background(MaterialTheme.colorScheme.secondaryContainer) - .heightIn(min = 48.dp) - .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() - ) - } - .padding(horizontal = 12.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.secondaryContainer) + .heightIn(min = 48.dp) + .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(), + ) + } + } + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - 8.dp, - Alignment.CenterHorizontally - ), + horizontalArrangement = + Arrangement.spacedBy( + 8.dp, + Alignment.CenterHorizontally, + ), ) { Icon( imageVector = Icons.Filled.Add, @@ -829,14 +893,20 @@ 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() + message.replyToMsgId != null || !quote.isNullOrBlank() || message.mediaKind != null || showSender || + !message.fwdFrom.isNullOrBlank() || + !message.viaBot.isNullOrBlank() TextWithTimestampLayout( textLayoutInfo = textLayoutInfo, - modifier = Modifier - .padding(horizontal = edgeContentPad) - .then(if (hasWideChrome) Modifier.fillMaxWidthInBubble() else Modifier), + modifier = + Modifier + .padding(horizontal = edgeContentPad) + .then(if (hasWideChrome) Modifier.fillMaxWidthInBubble() else Modifier), horizontalPadding = 8, stackedTopPadding = 2, textContent = { @@ -877,14 +947,18 @@ fun MessageBubble( } AnimatedVisibility( visible = onRetrySend != null, - enter = fadeIn(tween(160)) + expandVertically( - tween(220), - expandFrom = Alignment.Top - ), - exit = fadeOut(tween(100)) + shrinkVertically( - tween(180), - shrinkTowards = Alignment.Top - ), + enter = + fadeIn(tween(160)) + + expandVertically( + tween(220), + expandFrom = Alignment.Top, + ), + exit = + fadeOut(tween(100)) + + shrinkVertically( + tween(180), + shrinkTowards = Alignment.Top, + ), ) { OutgoingBubbleFailure(sendUncertain, sendError) { onRetrySend?.invoke() } } @@ -893,15 +967,17 @@ fun MessageBubble( repliesCount = message.repliesCount, onClick = onComments, contentColor = onContainer, - modifier = Modifier - .fillMaxWidthInBubble() - .bleedCommentFooter(edgeVisualMedia), + modifier = + Modifier + .fillMaxWidthInBubble() + .bleedCommentFooter(edgeVisualMedia), ) } } - val inlineMarkup = message.replyMarkup?.takeIf { - it.kind == ReplyMarkupKind.Inline && it.rows.isNotEmpty() - } + val inlineMarkup = + message.replyMarkup?.takeIf { + it.kind == ReplyMarkupKind.Inline && it.rows.isNotEmpty() + } if (inlineMarkup != null && onMarkupButton != null) { BotKeyboardGrid( markup = inlineMarkup, @@ -916,17 +992,18 @@ fun MessageBubble( onReact = onReact, onAddReaction = onAddReaction, onShowUsers = onShowReactionUsers, - modifier = Modifier - .fillMaxWidthInBubble() - .then( - if (hasComments) { - Modifier.padding(start = 2.dp, end = 2.dp, top = 4.dp) - } else { - Modifier - .offset(y = (-4).dp) - .padding(start = 2.dp, end = 2.dp, top = 6.dp) - }, - ), + modifier = + Modifier + .fillMaxWidthInBubble() + .then( + if (hasComments) { + Modifier.padding(start = 2.dp, end = 2.dp, top = 4.dp) + } else { + Modifier + .offset(y = (-4).dp) + .padding(start = 2.dp, end = 2.dp, top = 6.dp) + }, + ), ) } } @@ -954,38 +1031,41 @@ internal fun Modifier.bleedBubbleMedia( vertical: Dp = BUBBLE_CONTENT_VPAD, bleedTop: Boolean = false, bleedBottom: Boolean = false, -): Modifier = layout { measurable, constraints -> - val hInset = horizontal.roundToPx() - val vInset = vertical.roundToPx() - if (!constraints.hasBoundedWidth) { - val placeable = measurable.measure(constraints) - return@layout layout(placeable.width, placeable.height) { placeable.place(0, 0) } - } - val slot = constraints.maxWidth + hInset * 2 - val placeable = measurable.measure( - constraints.copy(minWidth = 0, maxWidth = slot, minHeight = 0), - ) - val x = -hInset + (slot - placeable.width) / 2 - val y = if (bleedTop) -vInset else 0 - val height = - (placeable.height - (if (bleedTop) vInset else 0) - (if (bleedBottom) vInset else 0)).coerceAtLeast( - 0 - ) - layout(constraints.maxWidth, height) { - placeable.place(x, y) +): Modifier = + layout { measurable, constraints -> + val hInset = horizontal.roundToPx() + val vInset = vertical.roundToPx() + if (!constraints.hasBoundedWidth) { + val placeable = measurable.measure(constraints) + return@layout layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + val slot = constraints.maxWidth + hInset * 2 + val placeable = + measurable.measure( + constraints.copy(minWidth = 0, maxWidth = slot, minHeight = 0), + ) + val x = -hInset + (slot - placeable.width) / 2 + val y = if (bleedTop) -vInset else 0 + val height = + (placeable.height - (if (bleedTop) vInset else 0) - (if (bleedBottom) vInset else 0)).coerceAtLeast( + 0, + ) + layout(constraints.maxWidth, height) { + placeable.place(x, y) + } } -} private object FillMaxWidthInBubble : LayoutModifier { override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, ): MeasureResult { - val childConstraints = if (constraints.hasBoundedWidth) { - constraints.copy(minWidth = constraints.maxWidth, maxWidth = constraints.maxWidth) - } else { - constraints.copy(minWidth = 0) - } + val childConstraints = + if (constraints.hasBoundedWidth) { + constraints.copy(minWidth = constraints.maxWidth, maxWidth = constraints.maxWidth) + } else { + constraints.copy(minWidth = 0) + } val placeable = measurable.measure(childConstraints) return layout(placeable.width, placeable.height) { placeable.place(0, 0) @@ -1021,32 +1101,39 @@ private suspend fun PointerInputScope.menuOrHold( val down = awaitFirstDown(requireUnconsumed = false) val slop = viewConfiguration.touchSlop var claimed = false - val released = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis + 48) { - var current = down - while (current.pressed) { - val event = awaitPointerEvent() - val change = - event.changes.firstOrNull { it.id == down.id } ?: return@withTimeoutOrNull false - if (change.isConsumed) { - claimed = true - return@withTimeoutOrNull false - } - val delta = change.position - down.position - if (delta.x * delta.x + delta.y * delta.y > slop * slop) { - return@withTimeoutOrNull false + val released = + withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis + 48) { + var current = down + while (current.pressed) { + val event = awaitPointerEvent() + val change = + event.changes.firstOrNull { it.id == down.id } + ?: return@withTimeoutOrNull false + if (change.isConsumed) { + claimed = true + return@withTimeoutOrNull false + } + val delta = change.position - down.position + if (delta.x * delta.x + delta.y * delta.y > slop * slop) { + return@withTimeoutOrNull false + } + current = change } - current = change + true } - true - } when { released == true -> { down.consume() 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 +1143,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/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/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 4bf724e7e..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 @@ -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) @@ -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) @@ -4766,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) @@ -5196,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, @@ -5335,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/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 c02a32d8b..0d38bcd46 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 @@ -130,95 +132,160 @@ fun SettingsContent( val layoutDirection = LocalLayoutDirection.current val direction = if (layoutDirection == LayoutDirection.Ltr) 1f else -1f val motion = mediaViewerMotionEnabled() - val pageBackHandler = remember(component, gestureDispatcher) { - MultiBackHandler(listOfNotNull(component.backHandler, gestureDispatcher)) - } + val pageBackHandler = + remember(component, gestureDispatcher) { + MultiBackHandler(listOfNotNull(component.backHandler, gestureDispatcher)) + } SideEffect { component.setPageBackHandler(folders?.onBack) } DisposableEffect(Unit) { onDispose { component.setPageBackHandler(null) } } - val pageAnimation = remember(pageBackHandler, direction, motion) { - val spec = - tween(durationMillis = if (motion) 280 else 0, easing = FastOutSlowInEasing) - predictiveBackAnimation( - backHandler = pageBackHandler, - fallbackAnimation = stackAnimation(fade(animationSpec = spec) + slide(animationSpec = spec)), - selector = { event, _, _ -> - predictiveBackAnimatable( - initialBackEvent = event, - exitModifier = { progress, _ -> - Modifier.graphicsLayer { translationX = size.width * progress * direction } - }, - enterModifier = { progress, _ -> - Modifier.graphicsLayer { - translationX = -size.width * 0.25f * (1f - progress) * direction - } - }, - ) - }, - onBack = component::popPage, - ) - } + val pageAnimation = + remember(pageBackHandler, direction, motion) { + val spec = + tween(durationMillis = if (motion) 280 else 0, easing = FastOutSlowInEasing) + predictiveBackAnimation( + backHandler = pageBackHandler, + fallbackAnimation = stackAnimation(fade(animationSpec = spec) + slide(animationSpec = spec)), + selector = { event, _, _ -> + predictiveBackAnimatable( + initialBackEvent = event, + exitModifier = { progress, _ -> + Modifier.graphicsLayer { + translationX = size.width * progress * direction + } + }, + enterModifier = { progress, _ -> + Modifier.graphicsLayer { + translationX = -size.width * 0.25f * (1f - progress) * direction + } + }, + ) + }, + onBack = component::popPage, + ) + } 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 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 - val peer = profile?.id ?: return@rememberEnsuredFile null - val key = peerAvatarCacheKey(peer, profile.avatarCacheKey) - when (val result = repo.ensureLocalAvatar(peer, key)) { - is Outcome.Ok -> result.value - is Outcome.Err -> null - } - }, - ) - - val accountTitle = profile?.title ?: stringResource(R.string.settings_account) - val accountSubtitle = profileSubtitle( - username = profile?.username, - phone = profile?.phone, - 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 + val cacheGeneration = + rememberCacheGeneration( + remember(component.mediaRepository, profile?.id, profile?.avatarCacheKey) { + profile?.id?.let { peer -> + component.mediaRepository?.cacheGeneration( + listOf( + peerAvatarCacheKey(peer, profile.avatarCacheKey), + peerAvatarCacheKey(peer) + ), + ) + } }, ) - - 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 + val selfAvatarPreview = + rememberStrippedPreview(profile?.id) { + profile?.id?.let { component.mediaRepository?.peerInlineThumbJpeg(it) } + } + 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 + val peer = profile?.id ?: return@rememberEnsuredFile null + val key = peerAvatarCacheKey(peer, profile.avatarCacheKey) + when (val result = repo.ensureLocalAvatar(peer, key)) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } }, ) - 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) - } + val accountTitle = profile?.title ?: stringResource(R.string.settings_account) + val accountSubtitle = + profileSubtitle( + username = profile?.username, + phone = profile?.phone, + 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.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.Wallpaper -> { + stringResource(R.string.settings_wallpaper) + } + + SettingsPage.Folders -> { + folders?.title ?: stringResource(R.string.settings_folders) + } + } Scaffold( modifier = modifier.fillMaxSize(), @@ -239,11 +306,12 @@ fun SettingsContent( if (home) { AppBarSyncTitle( idleTitle = barTitle, - sync = when { - state.loading && profile == null -> AppSyncStatus.Connecting - state.loading -> AppSyncStatus.Syncing - else -> AppSyncStatus.Hidden - }, + sync = + when { + state.loading && profile == null -> AppSyncStatus.Connecting + state.loading -> AppSyncStatus.Syncing + else -> AppSyncStatus.Hidden + }, ) } else { Text( @@ -287,10 +355,11 @@ fun SettingsContent( LogsTopActions( onShare = { val kind = debugLogKind(logKindName) - logShareFailed = !shareDebugLog( - logContext, - DebugLog.exportText(kind, logQuery), - ) + logShareFailed = + !shareDebugLog( + logContext, + DebugLog.exportText(kind, logQuery), + ) }, onClear = { confirmClearLogs = true }, ) @@ -329,9 +398,10 @@ fun SettingsContent( } } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), ) } }, @@ -345,165 +415,200 @@ fun SettingsContent( Box( Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) + .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, - accountUserIds = component.accountUserIds, - accountDetails = accountDetails, - activeAccountUserId = component.activeAccountUserId, - accountNeedsSignInIds = component.accountNeedsSignInIds, - onRemoveAccount = { confirmRemoveAccount = it }, - onAddAccount = component::addAccount, + 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 }, - onOpenExceptions = { component.openPage(SettingsPage.NotificationExceptions) }, + onKind = { kind -> + logKindName = kind?.name?.lowercase() ?: "all" + logShareFailed = false + }, + 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 -> { @@ -525,15 +630,22 @@ 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) + } } } } @@ -551,9 +663,10 @@ fun SettingsContent( confirmRemoveAccount?.let(component::removeAccount) confirmRemoveAccount = null }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + colors = + ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), ) { Text(stringResource(R.string.settings_remove_account)) } }, dismissButton = { @@ -585,9 +698,10 @@ fun SettingsContent( TextButton( onClick = { component.onLogout() }, enabled = !state.loggingOut, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + colors = + ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), ) { Text( stringResource( @@ -626,9 +740,10 @@ fun SettingsContent( byKind = state.cacheByKind, selected = selected, onToggle = { kind -> - clearSelection = selected.let { current -> - if (kind in current) current - kind else current + kind - } + clearSelection = + selected.let { current -> + if (kind in current) current - kind else current + kind + } }, onDismiss = { clearSelection = null }, onConfirm = { @@ -662,9 +777,10 @@ fun SettingsContent( confirmClearLogs = false DebugLog.clear() }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + colors = + ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), ) { Text(stringResource(R.string.settings_logs_clear_action)) } @@ -684,44 +800,51 @@ private fun SettingsPageList( content: LazyListScope.() -> Unit, ) { LazyColumn( - modifier = Modifier - .fillMaxSize() - .wrapContentWidth(Alignment.CenterHorizontally) - .widthIn(max = 720.dp), - contentPadding = PaddingValues( - start = 16.dp, - top = innerPadding.calculateTopPadding() + 8.dp, - end = 16.dp, - bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp, - ), + modifier = + Modifier + .fillMaxSize() + .wrapContentWidth(Alignment.CenterHorizontally) + .widthIn(max = 720.dp), + contentPadding = + PaddingValues( + start = 16.dp, + top = innerPadding.calculateTopPadding() + 8.dp, + end = 16.dp, + bottom = WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding() + 16.dp, + ), verticalArrangement = Arrangement.spacedBy(0.dp), content = content, ) } -private fun settingsPageKey(page: SettingsPage): String = when (page) { - SettingsPage.Home -> "home" - SettingsPage.Passkeys -> "passkeys" - SettingsPage.Devices -> "devices" - SettingsPage.DeviceScanner -> "device-scanner" - SettingsPage.Folders -> "folders" - SettingsPage.Data -> "data" - SettingsPage.Proxy -> "proxy" - SettingsPage.DebugStats -> "debug-stats" - SettingsPage.Logs -> "logs" - is SettingsPage.AutoDownload -> "autodownload:${page.network}" - SettingsPage.Appearance -> "appearance" - SettingsPage.Wallpaper -> "wallpaper" - SettingsPage.Notifications -> "notifications" - is SettingsPage.NotificationCategory -> "notifications:${page.kind}" - SettingsPage.NotificationExceptions -> "notifications:exceptions" - SettingsPage.NotificationDebug -> "notifications:debug" -} +private fun settingsPageKey(page: SettingsPage): String = + when (page) { + SettingsPage.Home -> "home" + SettingsPage.Passkeys -> "passkeys" + SettingsPage.Devices -> "devices" + SettingsPage.DeviceScanner -> "device-scanner" + SettingsPage.Folders -> "folders" + SettingsPage.Data -> "data" + SettingsPage.Proxy -> "proxy" + SettingsPage.DebugStats -> "debug-stats" + SettingsPage.Logs -> "logs" + is SettingsPage.AutoDownload -> "autodownload:${page.network}" + SettingsPage.Appearance -> "appearance" + SettingsPage.Wallpaper -> "wallpaper" + SettingsPage.Notifications -> "notifications" + is SettingsPage.NotificationCategory -> "notifications:${page.kind}" + SettingsPage.NotificationExceptions -> "notifications:exceptions" + 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/SettingsHome.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt index caf257d64..65cf3812c 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 @@ -47,6 +47,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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 @@ -81,6 +82,7 @@ internal fun LazyListScope.homeItems( stateSubtitle: String, avatarFile: File?, avatarPreview: ByteArray? = null, + avatarPoster: Painter? = null, appVersion: String, buildStamp: String, onOpen: (SettingsPage) -> Unit, @@ -105,6 +107,7 @@ internal fun LazyListScope.homeItems( subtitle = stateSubtitle, avatarFile = avatarFile, previewBytes = avatarPreview, + avatarPoster = avatarPoster, onClick = component::onOpenProfile, ) } @@ -113,14 +116,22 @@ internal fun LazyListScope.homeItems( 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() } - } + 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, @@ -130,27 +141,38 @@ internal fun LazyListScope.homeItems( title = title, size = 40.dp, imageFile = avatarFile, - previewBytes = avatarPreview + previewBytes = avatarPreview, + placeholder = avatarPoster, ) } else { SettingsAccountAvatar( - userId, title, details, userId !in accountNeedsSignInIds + userId, + title, + details, + userId !in accountNeedsSignInIds, ) } }, - subtitle = if (userId in accountNeedsSignInIds) { - stringResource(R.string.settings_account_sign_in_required) - } else identifier, + subtitle = + if (userId in accountNeedsSignInIds) { + stringResource(R.string.settings_account_sign_in_required) + } else { + identifier + }, iconColor = MaterialTheme.colorScheme.primary, - position = when { - accountUserIds.size == 1 -> ItemPosition.STANDALONE - index == 0 -> ItemPosition.TOP - index == accountUserIds.lastIndex -> ItemPosition.BOTTOM - else -> ItemPosition.MIDDLE - }, - onClick = if (active) null else { - { component.switchAccount(userId) } - }, + position = + when { + accountUserIds.size == 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == accountUserIds.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + }, + onClick = + if (active) { + null + } else { + { component.switchAccount(userId) } + }, trailingContent = { if (userId == activeAccountUserId) { Icon(Icons.Outlined.Check, contentDescription = null) @@ -165,7 +187,8 @@ internal fun LazyListScope.homeItems( ) } DropdownMenu( - expanded = expanded, onDismissRequest = { expanded = false }) { + expanded = expanded, + onDismissRequest = { expanded = false }) { DropdownMenuItem( text = { Text(stringResource(R.string.settings_remove_account)) }, onClick = { @@ -242,9 +265,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) }, @@ -348,10 +372,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, @@ -397,9 +425,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), ) } } @@ -418,41 +447,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, @@ -461,46 +498,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) @@ -530,18 +580,19 @@ private fun SettingsAccountAvatar( val generation by repo.cacheGeneration.collectAsStateWithLifecycle() val identity = Triple(repo, userId, avatarCacheKey) val preview = rememberStrippedPreview(identity) { repo.peerInlineThumbJpeg(peerId) } - 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, - ) + 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) } @@ -563,6 +614,7 @@ private fun AccountHeader( subtitle: String, avatarFile: File?, previewBytes: ByteArray?, + avatarPoster: Painter?, onClick: () -> Unit, ) { Surface( @@ -571,26 +623,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) } @@ -599,7 +655,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 { @@ -613,10 +673,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() + return runCatching { CountryManager.formatPhoneNumber(withPlus) } + .getOrNull() ?.takeIf { it.isNotBlank() } ?: withPlus } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f11acbd9c..61e706f86 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue Nov 10 21:37:06 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.2-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..ede9f467f 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -877,7 +877,6 @@ version = "0.2.0+layer.232" 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 4d1feae3f2ebd511fe0f7625774d72b7953d7ea8..aa03a4f875b6471fcf96d6f4b1e4f68fc913e8ba 100644 GIT binary patch delta 39741 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

    4+~+<~}{aVO&C z`>3BR%KLI!;9Axo4>ib=Hx069-Z2Y`0v;};ma$fa^87*erqi))3{jh;L%{jfmg0D`G!_Yq_$(g><-4o}6vxo$7B- zemmWmwIts18O?4H;(L>+T?yiveL3QRQ#9Qbh--ellrQX>J*^{^s6n%p?o~d-gN#=r z{xRb`pXKD>nA7R68KwRr?BjTEU3*y?aTM=h|JX{+Rw2p)j*M{AW z_$W32?tE-5Xu~!l?qv412#1YbTY>%M~L?sZocwAU@_dI)FCBvwo+%9r5?iQEu#1^XIvo54O#5&WLcCKhR7YPv#2Z-v zH{!b)FOTNxv22Ijlxz2Va*~Frj+OywJb?H&%v~emnth874_%piIOB!bg3=s05r65w zEFU_&NQal{aBqZ1O~fj;6su8#>M-3=Y7p1#8xYs*8xhy+TcSC{=d=c`(E@O-K|A7_ zJ$ErSbDF&aan0U|xN5J)=kk%DHEvWvv(t|*;gR0*?TWqBk{ZT2CYH0PJppFB(*igU*X*_HM9u#7LUxpKp$3&> z*kKRxGRBJ#-yts_VNaXNR5w!LN)1%4Z8=hR$Va2vdXaiuP9Ax=tq-X?Kk6t#$JJqaMbgY~}wam{}t;uENU57&$YEkF=)EkFz6T7Xu>%h&+gFlQYNMK~0Z zHlbR8cGN%%z=dPiY!wS&L|hwy4ROuCBbuv4tOamJ3&0y$fGotd0C|XO0rC;o{JRi8 z1^y$8s1ONSfFi`T040cP0o;f?#80V+@fEr1tu7N8PwEr1Vk&3|v-89quX85y}wXz|LvBI=mY3uiI(hTEsO!jg))3mY*iH=rjl+p4&kKv?H$hvGK7P z(s))3hxyk8SB!9(m+0_v9bT!!Yp%rA`fIq706eI}+jKa`?r&-}p$*KT!}EYg>K`{K z)M?<>;T1aEhxloB)AgqbjhBVU%~2Uo7`98rQ=h8oX9{36@iU5IP;B|5zPkDRW*N+f6(&^0=| zL5BwsZ)I2Xt%$dF^Sbd)TdM%~81~ zAYQ4%Yjk)6;@YKo5b^1n(1rw!bH>hNkE9*E{@Rce8oqXpoN?ABl_;trP55aJ)PgEME2&4lL1aRsi%=kkzH z%N)57uVzPpBE(<2pnBmrH{y4*sVqlav-g^l7DpOt8-v!shZ<}gPQOb~gSh4>fVk$T z5p#LdpX_NbGjFX(-6$XUlij1W3cs?uL3dFSz`J%bHC^VrqS^%EQQW`*9vv1bntvbB@3OuqXZAr1It6&?o z6LBqYz78+a;bl79o0JrZ-*vRs0#xfX2hLxl?%|TJJYt6q&(qlHc7fBO{$xXF-uF`1w?nWHGxIyDr{85NR z@FBkba~e2^_*64|+Y=rwh<`hSp3Ju*{_+vZ+wADZQ{P$ihEE7JXm-%dv?K1ypqyj6 zHkHN`G=LHDLdILgX6mA!D9=KCjY19b5PwS^UTpV#oM((= zr3k6d|C_cdL3~~u&7vFe9fj~cPk59ee#`BYmm}Uz?d9FY$({?hQX4O_`Ri?zS0diP z`rt!6t}fCAt{L$cf1o^w_~auI?%`UHu!nsT*NXTy_Q6^k;&JcM03pN;Ur^qT_y>)Y zb7^X2KzVB=aeyrvV??;j6IcKn;_k1hBM0JNvjI2}f9oS^pB2s3<+XY<74o75K)V_! z&qw?q>!=Iy*)*kGA>z+4|3!!!VPI-}t^^5pen$hl5&wb(E<^m}R%&04__KQ`uRy%9 zk#cVuy1dxmLWN4yz{f`5L;TZS)V><=FIh)x5I@FF+H1RbWc(aAg?-Kx=n|;oW;O*4 zh@aNJN{9HLn4f0Emv5r}f{0)Au6gJmpvFKa29qmlvDaMv;daNNmLUh^HN; zj$DZ6FkXoG88)z@7!LE_z}96+jKFdGS#h}$-^J|95HDk^rX2ADtf(sxe^9ko>(7gX zU5xTh$jUo&q918>uw(6@jpiFJzPE#K4+!pLi{b3*h0j6u|ro8 z;zw9XmLUEgwr{#49Og!=jBcms2+B}{HEd%pNBqx6sC@#Am!r2hiFR9~-svAJm}N0oovhxb!{c?TELr8sd7zmi%y*QDYa6jGyD~fmEmu zTbDrJA7*prK>Vvlsyh+y&pMig_(!i(`#j9^n2?VJ#$A}pn{KtIY3_>tpdJ9#&X%Yf zsrzL9HoIpZ^IVS95>`DGh#zM|^&k2M{00a^HaX2U;~pI83;<;6v;Lrx`UEe~M045b++Y+FB6b%L23_9=DVFX^ZA+ z!L_gvgrWuDi`W#jBff`ieO&L@a-PgaY(zZ%G!1M+TmpWbDmjpFitUL`#J^%A%R;=J z+2_fAH~euHQd3xX7vkTr$}2=%sHfd6LVP7Vp)5hXKRZWoQ$DV1Li@2}P#J2V@p8=N z;q&cjn(F<7I)A?1vx)8gKBQh(LpxoK_$5|UHHc4OovuZ^fbjt0Khx^>a1BV}{x24|9q~KZ?$7m!t>*u- zVlzf_b-4_tI|pZr7Jxr+g!a^dc+$_5I}v}7Ew3!ZuVsg+Jj4&d0Mz(gJ`&p4$Xtla ztY8Zfm;OULRD}4gEN}_p*RU%rcOP_oe1J_s8EPc&qd8ZweUSTz4r2O)= z<9SweHq>AO3+zDrQ5M*VxcL*Bxh%x{vQ?88!(sk!tf39^V+4-tb(r!(%(ZGqoSgu0 zC5T_I6&>P*3njY#d`Qs##;;a~H|p>f9d2~SZbYxEf#Hz_JW~JK+%&6yMGucW)WG{a zZIF+6%@35j5I@X#A>tD()W7>Or|WN#l?r8-g-AzUXS^KoIZ4#M0&z)+G~;}TcYaQ9 z#?~S(DI#sqC~qpW^DXM{`NquHbXBn%_gRQ**(yYQ4!faWhWN&G+Uat6_}z9-B~pK6 zTVDg>qx#ZTjfn4O)!CxMLx^ksjeR4dyS(l+!AXU#lMRjM>+m8SUZ%snF&ySy3lNAA z;8Q}DzXrr7w9>f>BEJ4b%G-1}*DrQHBlc?DIj$rC&qKU1n@&MK;jFvD8m{ zH17p7-^>KAe{9J#$OjhLJz6sxQa@&`@(>TP@f9Jyhz+9zan0V1xP!h==gN?vb*Nm< zyw{#xfz$@pEidBl$xZjd=T=PhAvM4zs}}L&qiD_oh->!Eh_^EPAmYr=V6FuTT1Q$D ze?(rs*ip!O8^R*v?TC9B=LW=1_M@wcDtpJjAutUWoVv)}bQA`!ik=;ZfU0E6ao%HMluQ1N#u4!yHv3 zK9%{YLA;Fd+GwsGNalV<12jepfR?e-kr3kTgUyi!?TF{tDdz^oX7tb&>feF*f*tGv zE)NOYzM>5Z5my_i4>#hkvRbJ?d>Ol^??Zgf5q3T`2wlKE%>p!{23gx^akU^G8WcGY za;=Dunn!sX;#D_O9_r$1{^3#eZ~AP^icOss*ok;!7Il<`cr6Q%hj`!?YM+mImWPe3 z2nkcOX@e5PKW2{Hh&yhh_FlxDLugG@BJS%+xu+Hh{aJ$m;ywD%1`UX7H4)U|ZHVWv zj)o||d?MMuA00tEYT#g;%Z{Czv#f|5h-)>Jhq$(y3S&6TzgE5O7=hzD*v?gfcn-U> z>(k-2h~KfD7H=cspO1wBtM%86gg`#cL=f={EOjl2YaKEUj?IvJDD`7QylM#L4&VdS zy4Oye9$|tLHK>&N2ko9VR;n(fwy{zzM4X#U!xSMt(?xj+;yG;5x)B%XmHh~=0tuzx z&_(Oh;k9z|Lw3GV{q1Q%HnZ7{lMv!T=F>PNHkTTA>hL1OXR>8fLixO|9DDoGb>v13 zT(8o>R3N_l5F4Zpua)@CIaWms>i1(OJ{f8*OyxrJtr$Rew zkiUR(ZfI=&3hU@9b|4-%hT1z3|9B+jSuq@z`yE$NAumSYxC^5x&qsVU3*bV05%W`s z_=RHXrwDQXaX9Fz+f5k~9{Q42h8J;dy;mcC2P^Fw#Lo_*mEDNAEr;@E;E}Fr+f5Jo z=)-nT=WZIl4K>qNs3SLaB6=`)PQ>rXreU&lcp>6W>c_(sA)%Z(D$(I?#Q*$XdNxym zc*}=$V6})l2hs@&L^#ZdmJi!O+MpRVxbq##TM>_A2bOlkb6AGBVX>LhCde4g)r!!L zP|j!pxS91d3-N$L*MbZ2!ztECPYV&(?A?fe4*wjX8lNjeg660kajhd2h}%kOO1+5p zV!RS@j&a|xB--PyDUg>xYEN`9xkf(lsQS;ts=tGg-))iKoILz7JMW16&WrpmRDXNi zd9exC&VMTq*UmP5h#zJvu~vsSBK}ERB;8yK<(D@%O$g~UFb zR;fiX9Hvqe%3=igch*=>y*j*FhX-_cGvblBYALlMVKeKgZA9#dwY#hi#5Mao#I>m` zL|n6X1CNxlR!N#rq0_*p!)p=$={TK&Mjaly%p>u+y@%;qu#Jozv5oOO#B137(jvrn zFkXVVHn8$Pa2lVh{DVM8P@}^eba+sQx9M=sGb(n(T2CE1JWq!g>ToyWgHF(aRWRQ5 zcN&`D(`itv!y9#Yiw+OPau25kHeMB*35`2-c)kuV(&1%Sr5z*64Wx$)^QEu+Q${O zh-({8BjOv_bwUf`c_-rTjFe+55{iD0V6F}E7REz}&ptsW?3z70?Qg?d854T3A-nSeO;#!sTyz5;UUDut#ow8{MfN; z+=+NSJ+FiBcwSlHigb7x;#x<&I=otk2O=EGR$Dciqh_53tvbA2hug-+W+lL4 z?$hD5T|6@W%Tv(UC8*=S%v*H0aeVBE7j>hj)HcL_sfoDbvJju(Nu&*2NSN7!T}>dK z*PZer#N$#aFF|}YyYo?r_#^YEy{8rlfjLwNAfCr~1LB7lQ~O55wJp3whleP?eA=!x zFiwa~sm7f;JRk9g&(KsCAwIN`^0F8X^REfs7=h!ie~C7z*5LufKW3R|K>TwpLx}HH zxmthCNO*w-Zbf|Yzi5Zrb+~O}Z03%!+k9C%+yy*Eb*b%uXI`TLN@V^YcD`KwJ){~n zu6T^jcMam+$E@;@e+<20FVh>4zQe=n1o2f~%9|0dW;`f2{llKzf>gLH{fB+}=gVo^ zHl+4iL3s%AEXLarA0-ce1}o&jW4gF-fV~m7s3dC>xJ+e*sw5$70pA#RkDeRhva zySPJY!@V?o3F4>9D0d_7n@xEc;@a@a5&wKq#NNYIAfb|di^+@l1ojbjCF1#v`w(}q zd8k1=pYhrVkDAcKiS&d$fEtvs1`UXhV)wop5qIZMKh20&Y9HD}b9HMgWC2>D1>hy) zsH0ZI%h{B*A-;<75aI#0x3(khfC^LNbKKl2`oF4yhgN|apSg&B zO;4*djaM>mWEH6KAmdpp(W%YnE%dfwK59_?AIgm^Q<@{=0Gc5i;^sRj*J?ns*N#Tc zF8fK50ddY}Xa=&ngjk-3`C4jkll`(i&B+qW!;a96HjMnPmYYMGNH{faU|GyA+j5cq=ayw8u9!jg_$w0H7Gh33Q{MO3%0WAUABZ@Z2n}AjVO^u?>@&TX?KpjyO zK7CpX|A!->=}{E^?>ayoYt3ouH>9*jdsNnX%M;hyEqyczo{h2}+ElDHTbxnUAvXc7 z1==@?LY{%P0PP<|AuRlBcdqe8E6U6QBf4~473vH=qL(#2HF5LKZ-)0*Fm1E&2s;B z8*MpD{`6sLU16W=e%?{`!m*BT<={HE?b)Mk|Ehkx{hj~jYr z-{e_Wb$m1O$47e{TyZK)A0hU9)&HEYyL-w@%lcbZJbm}7+osCC^?AJ!$+p>VKQyR$b-bzP+SS1ma$tkAzI8*NIKy3w zACOyj_NjN)EED8SJI6DSC6~Wzt>5tCM81B;%NcPR38%n6(zx7wX%gIgK zZ!yF-#vSv%DD$7(TmSl7*YaI00|!RRj~~mI^IkR9k8ZdjvHo1s7QR09N4rtLim>NJHxI%7s8_wE`;;!7sB71 z>J0x4^e+!~hOa*XcI}R zyuJ(UCP7DwAb5XhKMguF1^f?!*M%_9FvRKgm7z0ygL3g;+}9&KAA{4KuRxH)o#9>Z zoDYFZ-slXEh0aWXpIvXmNLo6>li>BIZ*_*pgKn+s3_k((r?+*6{Y{_&yB=&NpXv-Z zzR(%|1$Z&M?+Kl~0G)= z?T_bjz`sLq?z7JDzaa}j80ie?;YsNEPscmMRj+l1_X58jSOH{WKfJE`yfa+7t269@ z5&a29Zh*Ga;W-0F{TyWFcgVtS$Zjf3*MlE+h7UrU=V9c_*TZY@Ujzd`Zv>-mkj^ia zTaEEMr*^Y_AaJi&a@+)XZieU32g-=+;yP<2RZ7Cwrq%HQ@_BE^A#7JRM8e8`tO$+LQ~&5*`@NzogvXLwro& zUsHZg72|k#|GQm0!t=e!p)_$2KSjBeCJqyLgOb}z4D&*ppbYCHKEbC?PV{#edJQgw zh@ZnxUB7X4Z5P79#HQZL{tVH|3x|g(N1S3Go)?r$nc^^>=at-k;;TX;pXxtxAsoow zM7|v$_7g4+B!+?F3ZbzVd2*mQj(<`4XrTC-fuBzn4;Fh0$5P1CgT+4AZc6i?8D_4_ za2meYde1)FnIV?CGvMV+lgh31HSd7eN%ZxmUhvu$vETm?*pEr3{Ae%o>tL}jzks9; z5&Q9d$+#inUHoM7qWW$!*)>EQpPAXm|I<>wV<_(}K0T7}I1O?39ZmQD^bp_i6TJTC ze9|{Zyhb=`C->xt1Nls{CP$nr98FVB=7_fl&Ol->*Bax+YdVZ~-<@xa=V!y&P7nBJ zbTm%x8YX@P=_tq(H%o%_tMcPG@lubt9fLnDBBy4FPuhdO8Tc)Z&TBe;H*j0dP2v1C z5S6PYe=ia6s1Lx1~xTd4?Dz1uJDAyK?Px8Erd^TGg!aJ2qv!P!+ zM{c}Plwm4=yiu%&879w{i08Vwd;3?y#QmWDi8)N%vYjd9)tkh@sooU-yy0Axi|0n) zHG-@92(*GmyYj$nbJ523^;<;5E9S8DT^Tm7A5PeI7$s3FO3gu%c-eB9X) z$~_CkO9Ee`EWB5|%fNe;&&tImi4dURVR487_~J*!WrEO@qMY=I9bz1J&nM+d+mm9V z+KFGD7Da(yp`8DR_yW8q>(_`Gkd1fNh!2^-)rjZC+XVq)$Ti{~1MgODds)1l=U*fr zydqBGYsjTn#2G?k8kwvk-IjF<0iMJ_*0-BCWXUxwFXzbw#Syx6Y|b;V_t+j z6mY%$W$@nF^*-HyhpKZ-_mX3qVNSfn`YIHfi;Q?xoWVQEW3P(W@NTl}Rk2iPwUZ&W z;xy>N{k7uAOjj@eExov^oBME8Uf3rhGwkZ^f9F!z{uV?$)R*k46{mm?sZQ+d)|l=m z=~3|-9mKvxd{AgjBhPLT)1e5qY!OGo=)c_}_T+{3VM^y~;zG4J?tBBh)Q~N2Ko|Zs zf+TGfM}X6Ut>SF;E!hfdk|UpOg-U8oSB3;2v(WwAZQ`@=o_x4Xd{}5oAmg`-+o9Fz z?c$S=I_05y@p(awZGTJr1(r1Yt&nU`H;)(I5hVkpddvIbczF8$eX&z$O;Nu4K)hd2 z8_sPKR~XbS;;9Sa5Nr|0K8Av>Aq)Q{&P|)2;vbV-x&Hc#Vf%#)S4S#L2LY>d3&>ah z61PJ1zwZ*?Rrir=c1QM+6}v?{5A(jhS)5|vA1B{_BEtWnFvSn$RP2DCf0|*w1a5Ri z53VZlabn#oPU+p47FiXQurk_)a2?NqNe!5|smbEK;_S4VH2=KrTva3B2kl(dc|*pQ zO6Z34Ci!YF%&?mzdP;Ot9$UF}ZpfE(g+GrOrP zkaQjs*9-Y6gnTDv#?`EDwvqR~6SMgW^8I(v9iNhZT)a!@=k4jQFwrty5h>eV{=dR| zwM^Br?)yMi;4=BxYgu~Xs){6l2ZPvUI;Ugfu+M8fl1^7&6aQ_nUzmd<;Sg zMquR67sQOA=R~f@5h3N|&NyyP+e*&gYOrrP3MS{^^}?ND()x8lUF}QUfun}hE%V8y z3*z{q`@>;!1U8RWQ{%)|(F;F~6PH7@R)cq+4KwM z+ghQ_)DXwO<=1QYDy8hA=r#z?UwU(o@g;L&qN~FNJ2f|oJZLfQwoV@G-)Z1_Oou7X zZW>HxSdHt`ClB?n1GXG~n&C&CjEK$;Rl-(d0WWxmC{t|4`$YaCdDCt@$$vyPIgFLb z&OVVPF~1{hU!WcgTx49D@$Yf&uXou1G`-yv`F|GT`c@4&R_&*mv>N=QxDICmJg`%0pzDmL^#vkJNG;-Q)9AZf0 z{G@!8F;kg1-xwOfuOqotK-Yn7gg#njEKD@4n*dEG8PmJX?Q)r@x>R|@EaiFQDBh4Z zj)o?GdBJFlyo$xC&}c}T$i#d18*O}=^4Nakk~rw}>4R#gL5$c={M^{i8`eQ@A^`_o4tV0C z+G`LZc8?!22B2e;m|gc6JLnh)s@;>1hm6YOp~O!XcXv;$FYgN%P6ab_{8Ku^fzreI zr1W;_x9-lXB0`{G?>JTPfuP*umaGy#S$TM&)LjtVx#ZbJ($(?qT)#1ye6~oM#Fr`E z?vsB_(FQ`trZnUi^ z76|a=EI-_Fi#LR%f4Z7w4Kjbm!#bMfV9sHXR3o&Nc;Ira(;z$JKtNG zw^EwI^Xba-tE7?Y^JkAsNy31QJ^lUQlA-jLUj9vm-r`;+zKU~lRWSNsfu>C$36;_y zXp&ngy(P@(&l-tzm0^!EWM5V?w9)U0!d#ZjTGXBky&e`5o!5-{IZd+GW6!E+;DW? z49h%X{Sln0NMnRmB00E5x=}C=B12^9N4}1vR!Lv;mC7$wl85K}ll#|7w+l1-l7nld z`(eP-hy&P4Hr7`?i^8I@0X*janzd?F|zm9a6{ir-i*nvT~C&O$dp~Cz~Y6AiOz%eEG6eEQ}pMhP)yT z7VaNP=Ds2o-x@Ti>GYMZ?(Sdyfwy>zfv;NPh@>-6@LnR-sz&)I|Ku%x3A8fMBFTHt zLA6j{|Iv}&;*Frq*V?U$r&<|F*ety&tTvLZo27ZeB9WM0l@<(iFt-zW`uSlOT z&mo`HO0(n32B}3nxK7F#(dhIyK}+d!OMjC9S5@%-ci4ivLn$s5?0$O!)DYCp#<{)8 zvN~yWdfq@kY|Vq;5nx`~F^Y#CWxhO+d{QT+3vMI%p-vhhm`9SHTcmXUV={V+R4Sax zCL6X$qlKw3=Ub#}r87zXh+B`940=s^F?DLZf0Mym4EI2LsE55w)#U{zIr5q`&of7a zkoGO#8G3H9LsQi$5Bz3#+6}0wHTLwyl$~#-7S7GIn1>2+#6LW0T;c+r}yEGT*itW-%f_XFYHwnN(~lhT6F^Hd`H^X_m->uyFlY%^XLb5n>0J% zk#5&m$jNu5wJ^FB?}6`h^8I_T4>c=;-)zhfiy+1Ws|2rkTwf>ab)C&(n7&K zfV}Xb^n9Xm1RZ1!nbs(MH?YdAz73Y19p|4s*IT^j0#{X-%vBwOz88QN8AE+Y z|8HqlVvzOv4&~}S(n>xNk`hUHyRzpK=^g_DM_m6y?VK((Z)B z9U{eUGVqYJlh0IsJS3UogkHJi+*i_QK1g!EmU;>w4|yC6L71AX%swLhVvx*P>ZAlqONNtK-$`!? zWtl`gF74_DwH=u>Z?>xYO1UA*k>jwX3u%eUC*Moo@>2OA)xuZMV~kSYCc%A^Ck81y zPe5f4q)M>Bo8-R>P865Jjx+5SIAH{9?y{!bd=hpOp>Lw{&?#wN+)Wk3{L3Hr79R_7 zRWswcsz|9E@8xd-_6@LWD1!s~ct2n1Ep7o;0BonaEIuCNpTEjmd=S_~&8}>`|L9ZR z;(a0V*vF>6%FLgoOhaPv)&36X#@Kt52hU1&UifAZdG3;S3;6-UO9O53nB<+T$f-oGdUmi@tVJR1$pBrY%6$*#EdAsSh zcuShA!^pcjI6mW$F@@}jH;v4O>$gbYUh$E$kw`wz_M`K(F#(nx87 z=?S5I06CdpS|gn8M?9h_m2W3&MN=uiQ8_P~!hFi20saF&!sZCqEO&Qvy{(?-7?TyN zWSS*h)nbWwg?SDZz)FwIi9?LA+MO8NO;qj{lMHW4#Sec=RdaE57TTsJT{3Rgs}{rEaC400ESE8%`7x4|5c8lU{k7+g`D zeeoLQo1knf(-41mI8@Yu<7|GA-v+P8!jF0pqmESliD*q(e)Z5sU*{?($C}nV`E2Ehd8T(Q za0yUQX8KxyGrK|l4UuX6tbrVn|3zB1w=6P#~NQPw|f z>QrZ7?IWfc{C~)?M@;*n8n-`cDiyeF#r~K{kl=nxdZo!e9(p@sr)jR)^3?!;QtQfY z)o@|vxLsMh-}KNleuiRAHNV93v&nY5`D=K3#$g@k$CY) z^R<5_=lh%I@;8y<0cOJAN`4$*9>m|ObQ@^iVBnXNH?pBWndDfuc`0nIHx4%cAjF%~ z{n-|!V5oWIK)zUcaJCuBw3s|G$9ywCQaLuqypvBJIeKghoQ=$f$m#~08K=B;qxqh3 znahXxBR%;~oUKf7b%eWf9VdqHb#-t8)>mCTZc^Yk58)HZV!!#(xL`raByz@YE{1y) z1#8SJh2=xY?ltBmCbKh;pF4$7tMAH z%-4-u%?H)_8WJ#npCg>eA>F?*|0w)BR|$V(PPYmtMk+l|nG<-vPU-uT`J5SMEB(B= zdqVtPr$75I&yBF`j&B>`&rW>vk|iUapHBKDSVqEZ7bjTYB6fzdP_(=&q`^hUmv90Z z+#DtYpxle$w6&=t9L&riS4)=JnYX$+VCu%ILnUAaAA|P@!~1NW^QlvseJj~6S?2P* zBAG0|8u*3elGU;wM!7G^GE%6>AqJb}G0XA?#?}eksBAcsOI)rzW3w0p=*OCFmY#gt zeAx0j;Cvt-&K;(3ez*eyyCN4dWh=Y7Te|a!_rgt;4mhlYlw&OKXrot9*dHiV}YeI zoh;6^j3CBb%U_@^ydE?hQnL1TRhyyQIA0CbL`IZaUIqE9QcGrhWG^A%GRwlqjbSjH zq6}VWN#Ut53zfxw7;fU& z>c~x_>6^(jt1YQYxola^C+0A?jzZgWmj4)p)}G{KwWWZUNbWkz6yB)Zzs_Pv<>!(I zw_0ZKe$`80XIEQNNHAcz3eu3U%>rldUZqdHWd_{vCJW!R zY~cHmgtsj9a0c|wTQCo;y_K64%k%2J%<~PFPk30!yWRoWsXY9y^#_(T{##}H2Nt*||C!RK5lWN)Hz{ti+|2J%-e|JC!1He_*YAX4;UUcbtXJI!b@P%?rwU%Rex@cx*01ni$#x=x z>!<^69013eot*#2esOheFF`RUDyP4&JQOEA;ObZaYd~GYOTM!7jlQzfXFN90%r=T@dz904#A7{=1?_~Q}>ul+7aO+G((2=BZ)||M=t4ZUmSHs5k z*f^`5H%-HZwK63h^dTmWzQ*^XD=MTbFM+V2;_fMXilL?8+H0JtSffvBFFUUe@{ziAnH0;F|~! zPWzM$EVs_$H<1U+tyB1)m3PXmP~bn2)Aw0>j`(SsS}!{;&6-vh-P;--dJ9!BkoD*% zF49{c^i~;vzf}@aAM=+l?dIx$L<9G$u<_wA^Q>lO0dOt6;=8{2=Sicfhk5CMc zTIcbpL%UoC3o4V;ulJ=S+pdq-(5)|XMaipOhR zE5!S!b!0!Mq_4Do5}*1XUt~anIyt_?+TPCiKO-66Ap()Nk7Y14gWfmE1$v{7RK`8Q`gP`0U-6OV5%y#Bw zEx@gIQca|O{&d+-kSmwe+cHxz%d zF+{Y?MA|MpT5BJxx_<-Q4+swR_hb_EIK;g?GIGwUj)+%F`D~tQ`r{gF&*`hW`QgiM z+Ta4*BNHPL)Lv!60M+%THjB6krFLmNXnmo|AA(O2R2|%1FI&f_uJV;v(Q5Ol(+?96 zgy|i?em+E3X>b+tcB38kKdakG`<3Zz7ALy!* z5UYe>DU{Qa=d8Q<)H|cZ9S+*dnp`r2fY-l7X|#kQ4L!s)gWbo(wR201hnZhV>M_OD7~YH-wmmH zECFt}!m|-BrXPaWvsudKU;BqPzS92W(0Xe|O5v3xMcQEX@Tn817FkyLaQ{)AId3L; zVIy>L+#j@1X=S2vaHDmfnA-WQwjM)l$^+_@D=$OCjw@>?RjvKXTE;7&{obVo+3W>i z_Q>p4tbJ3?Mro&bn&%es_gAd_Qh)t}6evA@71;^RQh$!sBB|VABApN}kVanAVZOC!Tys;N~oY_7BM9SgmzRyqo|M+`_eH@I<+`&bpP4-P9YYu`Y{0 ztG-kod(D~=m-13qus}iKFt(?X?OUxwQa8m)Ff3o`s;kMbTdh}%bLd4pc_d(U*pL2C zl%+9E&1s-?szB}vSaVZ0bZNeV!*EkmO-`G#C1Cwfh=0b_VOu~J*IVs9*TBmaaek+e zai|DRO_JBb`xohZ#anMJH;^0tDR@cVmsTUGR;;$Pr5t_00hUW+uhpj&q)CC(l%>S~ zL*(gac-Lk8C-uxN%6JjUYyhi8o`b0O)!wRfd0O-7i4M|4*}r$?)&3*N{A$ zU{t){Tgx51k^FoKR&o}(FARNiDE_eZ1>RFsPro7lzw`|MB**`12jE-t|EnFC3iJAZ z-2vriA?evRav{Ee?)!sBg7D{c(`7sYzo;}lG_UCH`Ln%`s^Uf?a=Ku_9qH$W>x^?*g 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 e021db01c9d54350146da198e5519b62d2dc057c..f675c1d977b101cbc1878a063b5b723b4bda75e8 100644 GIT binary patch delta 71693 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�s*IW+LRB>*a%Pg$ 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 D_<(f8 delta 71579 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 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 6ee0245455154a849de2f265e9380d5f1b52ccba..685b9fbad5a46cea3f6025848e6e1d1271ef1ea0 100644 GIT binary patch delta 59645 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 delta 59562 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 A>i_@% diff --git a/native/mtproto-rs/src/ffi.rs b/native/mtproto-rs/src/ffi.rs index a4e7fe036..a3788beb0 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; 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/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/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/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/SessionCore.kt b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt index 56faa904c..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,21 +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()) { @@ -107,9 +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() } } @@ -136,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, - ) - - is QrLoginDto.Authorized -> { - maybeStartUpdates(handle) - QrLogin.Authorized( - AuthSession(PeerId(result.session.userId), result.session.dcId), - ) + 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), + ) + } } - } - }) + }, + ) } catch (_: MtprotoException.PasswordRequired) { Outcome.Ok(QrLogin.AwaitingPassword) } catch (e: CancellationException) { @@ -161,14 +176,15 @@ internal class SessionCore( } } - override suspend fun pollLoginToken(): Outcome = try { - Outcome.Ok(onNative { handle -> native.pollLoginToken(handle) }) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - // Pending QR authorization belongs to AuthStore, not the signed-in account. - fail(e, "auth.pollLoginToken failed", degradeHome = false) - } + override suspend fun pollLoginToken(): Outcome = + try { + Outcome.Ok(onNative { handle -> native.pollLoginToken(handle) }) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Pending QR authorization belongs to AuthStore, not the signed-in account. + fail(e, "auth.pollLoginToken failed", degradeHome = false) + } override suspend fun getDeviceSessions(): Outcome> { when (val connected = ensureConnected()) { @@ -198,7 +214,8 @@ internal class SessionCore( } return rpcWrite("revokeDeviceSession failed") { handle -> native.revokeDeviceSession( - handle, hash + handle, + hash, ) } } @@ -214,25 +231,44 @@ 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) + 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 deadSessionError(): Outcome.Err { @@ -242,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 @@ -260,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) @@ -270,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( @@ -281,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 = @@ -297,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) @@ -318,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) @@ -334,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()) { @@ -368,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) @@ -377,8 +433,10 @@ 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, @@ -386,31 +444,42 @@ internal class SessionCore( ): Outcome = rpc(fallback, DispatchClass.INTERACTIVE_READ, degradeHome = false, block) internal suspend fun rpcBackground( - fallback: String, block: suspend (Long) -> T + fallback: String, + 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() + @Suppress("UNCHECKED_CAST") + return (shared.deferred as Deferred).await() } finally { withContext(NonCancellable) { 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() } } @@ -423,31 +492,42 @@ internal class SessionCore( dispatchClass: Int, degradeHome: Boolean, block: suspend (Long) -> T, - ): Outcome = retryShortFlood { - try { - perfOp("bridge:${fallback.removeSuffix(" failed")}") { - Outcome.Ok(onNativeClass(dispatchClass, block)) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - if (isStaleHandleFailure(e)) { - AppLog.api("mtproto", "stale handle; recreating") - resetHandle() - try { - perfOp("bridge:${fallback.removeSuffix(" failed")}") { - Outcome.Ok(onNativeClass(dispatchClass, block)) + ): 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(block()) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (isStaleHandleFailure(e)) { + AppLog.api("mtproto", "stale handle; recreating") + resetHandle(requestGeneration) + try { + perfOp("bridge:${fallback.removeSuffix(" failed")}") { + Outcome.Ok(block()) + } + } catch (retry: CancellationException) { + throw retry + } catch (retry: Exception) { + fail(retry, fallback, degradeHome) } - } catch (retry: CancellationException) { - throw retry - } catch (retry: Exception) { - fail(retry, fallback, degradeHome) + } else { + fail(e, fallback, degradeHome) } - } else { - fail(e, fallback, degradeHome) } } - } /** Skip native.connect (main lock) after a successful connect on this handle. */ internal suspend fun ensureConnected(): Outcome { @@ -477,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) } } @@ -501,12 +582,19 @@ internal class SessionCore( } try { if (native.isAuthorized(activeHandle)) { - publishSyncState(activeHandle, true) native.startUpdates(activeHandle) native.updateLazySyncConfig( - activeHandle, lazy = true, exceptions = emptyList() + activeHandle, + lazy = true, + 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) } @@ -515,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) + } } } } @@ -525,9 +617,10 @@ internal class SessionCore( native.setTestDc(activeHandle, enabled) } - override suspend fun usesTestDc(): Outcome = rpc("usesTestDc failed") { activeHandle -> - native.usesTestDc(activeHandle) - } + override suspend fun usesTestDc(): Outcome = + rpc("usesTestDc failed") { activeHandle -> + native.usesTestDc(activeHandle) + } override suspend fun connect(): Outcome { if (sessionDead) return deadSessionError() @@ -539,9 +632,10 @@ 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 } + 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) @@ -549,27 +643,32 @@ internal class SessionCore( AppLog.api("connect", "start handle=$locked") if (nativeProxyConfigured()) proxyState = ProxyConnectionStatus.Connecting 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 + 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) } - connectedHandle = 0L - if (nativeProxyConfigured()) proxyState = ProxyConnectionStatus.Failed - throw e - } finally { - DcTxtBootstrap.logNativeStatus(sessionPath) + activeHandle } - activeHandle - } - if (connected is Outcome.Err && rejectedBindingHandle != 0L && isCurrentHandle( - rejectedBindingHandle + if (connected is Outcome.Err && rejectedBindingHandle != 0L && + isCurrentHandle( + rejectedBindingHandle, ) ) { bindRejection = BindRejection(rejectedBindingHandle, clock.elapsedMs(), connected) @@ -606,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 @@ -629,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 @@ -659,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( @@ -703,30 +805,40 @@ 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) { + + override fun applyDownloadConcurrency( + lanes: Int, + parts: Int, + ) { runCatching { native.setDownloadConcurrency( - lanes, parts + 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}") } + runCatching { native.setFilePartKib(kib) } + .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}") } + runCatching { native.setDownloadChunkKib(kib) } + .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) @@ -734,7 +846,8 @@ internal class SessionCore( override fun setDownloadProgressListener(listener: ((String, Long, Long) -> Unit)?) { runCatching { native.setDownloadProgressListener(listener) }.onFailure { AppLog.warn( - "download progress", "listener failed: ${it.message}" + "download progress", + "listener failed: ${it.message}", ) } } @@ -745,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 { @@ -768,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. @@ -792,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() } @@ -831,7 +949,7 @@ internal class SessionCore( config.port, config.username, config.password, - config.secret.copyOf() + config.secret.copyOf(), ) proxyState = ProxyConnectionStatus.Disconnected } @@ -842,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()) { @@ -883,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) { @@ -902,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/UpdatesPump.kt b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesPump.kt index 19f3923df..ccf3bdeba 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,7 +33,9 @@ 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 @@ -63,15 +66,20 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { 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, - ) + 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, + ) + } } } } @@ -86,13 +94,45 @@ 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) @@ -118,58 +158,70 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { lastChatsRefreshMs.set(-1L) lastPremiumRefreshMs.set(-1L) } - val drain = try { - if (!replayPendingBatches(activeHandle)) { - delay(drainDelayMs) - continue - } - publishCompletionIfReady(activeHandle) - val drainAt = if (PerfLog.isEnabled()) System.nanoTime() else 0L - val drained = nativeRequest( - core.native, core.nativeDispatcher, DispatchClass.BACKGROUND_READ - ) { - core.native.drainUpdateBatches(activeHandle) - } - val count = drained.batches.sumOf { it.events.size } + drained.liveEvents.size - if (count > 0) { - PerfLog.noteUpdateDrain(count) - } - if (drainAt != 0L && count > 0) { - PerfLog.trace( - op = "updates", - phase = "drain", - elapsedMs = (System.nanoTime() - drainAt) / 1_000_000, - handle = activeHandle, - dispatchClass = DispatchClass.BACKGROUND_READ, - result = "ok", - detail = "count=$count batches=${drained.batches.size} lane=${ - dispatchClassName( - DispatchClass.BACKGROUND_READ - ) - }", - ) - } - drainDelayMs = 25L - reconnectHandle = 0L - if (drained.replayRequired) { - core.replayPending.set(true) - if (core.durableSink == null) drainDelayMs = REPLAY_WAIT_MS - } - 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) { - reconnectHandle = activeHandle + val drain = + try { + if (!replayPendingBatches(activeHandle)) { + delay(drainDelayMs) + continue } - drainDelayMs = (drainDelayMs * 2).coerceIn(4_000L, 30_000L) + publishCompletionIfReady(activeHandle) + val drainAt = if (PerfLog.isEnabled()) System.nanoTime() else 0L + val drained = + nativeRequest( + core.native, + core.nativeDispatcher, + DispatchClass.BACKGROUND_READ, + ) { + core.native.drainUpdateBatches(activeHandle) + } + val count = drained.batches.sumOf { it.events.size } + drained.liveEvents.size + if (count > 0) { + PerfLog.noteUpdateDrain(count) + } + if (drainAt != 0L && count > 0) { + PerfLog.trace( + op = "updates", + phase = "drain", + elapsedMs = (System.nanoTime() - drainAt) / 1_000_000, + handle = activeHandle, + dispatchClass = DispatchClass.BACKGROUND_READ, + result = "ok", + detail = "count=$count batches=${drained.batches.size} lane=${ + dispatchClassName( + DispatchClass.BACKGROUND_READ, + ) + }", + ) + } + drainDelayMs = 25L + reconnectHandle = 0L + if (drained.replayRequired) { + core.replayPending.set(true) + if (core.durableSink == null) drainDelayMs = REPLAY_WAIT_MS + } + 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 + ) { + reconnectHandle = activeHandle + } + drainDelayMs = (drainDelayMs * 2).coerceIn(4_000L, 30_000L) + } + UpdateDrainDto(emptyList(), emptyList(), false) } - UpdateDrainDto(emptyList(), emptyList(), false) - } if (drain.batches.isNotEmpty() || drain.replayRequired) { durablePendingHandle = activeHandle } @@ -184,15 +236,19 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { // 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() - if (foldersRefreshPending && cooldownDue( - lastMetadataRefreshMs, 2_000L, now + 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() } - } + val folders = + core.onNativeIfFree { + core.native.getFolders(it).map { dto -> dto.toFolderModel() } + } if (folders != null && core.isCurrentHandle(activeHandle)) { lastMetadataRefreshMs.set(core.clock.elapsedMs()) foldersRefreshPending = false @@ -201,20 +257,26 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } catch (e: CancellationException) { throw e } catch (e: Exception) { - val raw = when (e) { - is uniffi.monogram_mtproto.MtprotoException.Message -> e.v1 - else -> e.message - }?.removePrefix("v1=")?.trim().orEmpty() - val telegram = TelegramError.parse( - raw.ifBlank { "refresh folders failed" }, - "refresh folders failed", - ) - val waitSec = if (telegram.kind == TelegramError.Kind.Flood) { - (telegram.retryAfterSeconds ?: telegram.argument ?: 3).coerceIn(3, 60) - } else { - core.fail(e, "refresh folders failed", degradeHome = false) - 15 - } + val raw = + when (e) { + is uniffi.monogram_mtproto.MtprotoException.Message -> e.v1 + else -> e.message + }?.removePrefix("v1=")?.trim().orEmpty() + val telegram = + TelegramError.parse( + raw.ifBlank { "refresh folders failed" }, + "refresh folders failed", + ) + val waitSec = + if (telegram.kind == TelegramError.Kind.Flood) { + (telegram.retryAfterSeconds ?: telegram.argument ?: 3).coerceIn( + 3, + 60 + ) + } else { + core.fail(e, "refresh folders failed", degradeHome = false) + 15 + } lastMetadataRefreshMs.set( core.clock.elapsedMs() + (waitSec - 2).toLong() * 1_000L, ) @@ -223,15 +285,16 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } } } - if (chatsRefreshPending && core.isCurrentHandle(activeHandle) && cooldownDue( - lastChatsRefreshMs, 2_000L, now + 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() - } + val chats = ChatApi(core).getChatsIfIdle() if (chats != null && core.isCurrentHandle(activeHandle)) { lastChatsRefreshMs.set(core.clock.elapsedMs()) chatsRefreshPending = false @@ -240,31 +303,37 @@ 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 + if (premiumRefreshPending && core.isCurrentHandle(activeHandle) && + cooldownDue( + lastPremiumRefreshMs, + 60_000L, + now, ) && premiumInFlight.compareAndSet(false, true) ) { core.scope.launch { - val profile = try { - core.onNativeIfFree { handle -> - core.native.getProfile(handle, 0L).toProfileModel() + val profile = + try { + core.onNativeIfFree { handle -> + core.native.getProfile(handle, 0L).toProfileModel() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + lastPremiumRefreshMs.set(core.clock.elapsedMs()) + core.fail(e, "refresh account failed", degradeHome = false) + null + } finally { + premiumInFlight.set(false) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - lastPremiumRefreshMs.set(core.clock.elapsedMs()) - core.fail(e, "refresh account failed", degradeHome = false) - null - } finally { - premiumInFlight.set(false) - } if (profile != null && core.isCurrentHandle(activeHandle)) { lastPremiumRefreshMs.set(core.clock.elapsedMs()) premiumRefreshPending = false @@ -293,31 +362,48 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { * 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 commitDeliverAndAcknowledge(handle: Long, batch: UpdateBatchDto): Boolean { + private suspend fun commitDeliverAndAcknowledge( + handle: Long, + batch: UpdateBatchDto, + ): Boolean { val sink = core.durableSink ?: return false - val committed = try { - sink.commit( - DurableUpdateBatch( - accountGeneration = batch.accountGeneration, - batchId = batch.batchId, - updates = batch.events.flatMap { it.toMtprotoUpdates() }, - cursor = batch.cursorAfter?.let { - UpdatesCursor( - it.pts, it.qts, it.date, it.seq - ) - }, - channelCursors = batch.channelPtsAfter.associate { it.chatId to it.pts }, - ), - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - AppLog.warn("updates", "durable commit failed") - false - } + val mapped = + try { + batch.events.map { it.toMtprotoUpdates() } + } 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 = mapped.flatten(), + cursor = + batch.cursorAfter?.let { + UpdatesCursor( + it.pts, + it.qts, + it.date, + it.seq, + ) + }, + channelCursors = batch.channelPtsAfter.associate { it.chatId to it.pts }, + ), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.warn("updates", "durable commit failed") + false + } if (!committed || !core.isCurrentHandle(handle)) return false if ((batch.accountGeneration to batch.batchId) !in emitted) { - deliverEvents(batch.events, batch.accountGeneration, batch.batchId, handle) + deliverEvents(batch.events, batch.accountGeneration, batch.batchId, handle, mapped) if (!core.isCurrentHandle(handle)) return false rememberEmitted(batch) } @@ -358,8 +444,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 -> { @@ -380,17 +467,31 @@ 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) ?: event.toMtprotoUpdates()).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) } } } 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 7296982e0..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,611 +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") }) - 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() + 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, @@ -1657,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, @@ -1703,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/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 4aea2f5fc..a59e3c3a5 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 @@ -20,11 +20,10 @@ 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.collect +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 @@ -57,18 +56,26 @@ fun peerPhotoSmallCacheKey(photoCacheKey: String): String = } internal fun streamPrefixPart( - directory: File, cacheKey: String, offset: Long + directory: File, + cacheKey: String, + 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}" -} +internal fun streamMediaKey(message: Message): String? = + message.mediaCacheKey?.let { + "$it:chat:${message.id.chatId.value}:message:${message.id.id}:size:${message.fileSize ?: 0}" + } /** A prefix part the player can read without fetching again. */ internal fun reusableStreamPrefix( - file: File?, offset: Long = 0, size: Long? = null + file: File?, + offset: Long = 0, + size: Long? = null, ): File? { val expected = size?.takeIf { it > 0 }?.let { minOf(512L * 1024, it - offset) } ?: 512L * 1024 return file?.takeIf { expected > 0 && it.isFile && it.length() == expected } @@ -103,11 +110,17 @@ fun interface TelegramMediaFetcher { ): Outcome /** 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 { @@ -138,12 +151,13 @@ class MediaRepository( fun createCachedDataSourceFactory(key: String): DataSource.Factory = DataSource.Factory { LeasedFileDataSource(cache, key) } - private val cache = FileCache( - cacheRoot, - maxBytes = cacheLimitBytes ?: 1L, - maintainOnInit = false, - enforceSize = cacheLimitBytes != null, - ) + private val cache = + FileCache( + cacheRoot, + maxBytes = cacheLimitBytes ?: 1L, + maintainOnInit = false, + enforceSize = cacheLimitBytes != null, + ) private val progress = MutableStateFlow>(emptyMap()) private val generation = MutableStateFlow(0L) private val cacheClearedGeneration = MutableStateFlow(0L) @@ -168,7 +182,9 @@ class MediaRepository( private val telegramPending = PriorityQueue() private val telegramJobs = HashMap() private val pendingConsumerPriority = HashMap() - private val nextConsumerId = java.util.concurrent.atomic.AtomicLong() + private val nextConsumerId = + java.util.concurrent.atomic + .AtomicLong() private val telegramWorkers = mutableListOf() private var telegramSequence = 0L @@ -183,7 +199,10 @@ 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) } @@ -214,11 +233,15 @@ class MediaRepository( return bytes.takeIf { it.isNotEmpty() } } - private fun cachedInlineThumb(key: String): ByteArray? = synchronized(inlineThumbs) { - inlineThumbs[key] - } + private fun cachedInlineThumb(key: String): ByteArray? = + synchronized(inlineThumbs) { + 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) } @@ -239,27 +262,45 @@ 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() } - }!! - try { - emitAll(entry.generation) - } finally { - keyedGenerations.computeIfPresent(key) { _, current -> - if (current === entry && entry.collectors.decrementAndGet() == 0) null else current + 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 { + 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 { + 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) @@ -268,20 +309,23 @@ class MediaRepository( fun progressBytes(key: String): Long = progress.value[key] ?: 0L - private val queueDelegate = lazy { - DownloadQueue( - client = httpClientFactory(), - scope = scope, - maxConcurrent = 2, - ).also { it.start() } - } + private val queueDelegate = + lazy { + DownloadQueue( + client = httpClientFactory(), + scope = scope, + maxConcurrent = 2, + ).also { it.start() } + } private val queue by queueDelegate fun cachedFile(key: String): File? = cache.get(key) fun acquireCachedFile(key: String): FileCache.Lease? = cache.acquire(key) - private val usedBytes = java.util.concurrent.atomic.AtomicLong() + private val usedBytes = + java.util.concurrent.atomic + .AtomicLong() /** Bytes of cache files a consumer actually opened. Prefetch can compare this with transferred bytes. */ fun usedByteCount(): Long = usedBytes.get() @@ -293,30 +337,42 @@ class MediaRepository( return lease } - suspend fun removeCachedFile(key: String): Boolean = withContext(Dispatchers.IO) { - cache.remove(key).also { removed -> if (removed) markCached(key) } - } + suspend fun removeCachedFile(key: String): Boolean = + withContext(Dispatchers.IO) { + 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() - } + 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) } + .onSuccess { markCached(alias) } return } runCatching { @@ -351,13 +407,15 @@ class MediaRepository( fun cacheByChat(): Map = cache.usageByChat() - fun clearChatCache(chatId: Long): Long = cache.clearChat(chatId).also { - if (it > 0L) markAllCachedFilesChanged() - } + fun clearChatCache(chatId: Long): Long = + cache.clearChat(chatId).also { + if (it > 0L) markAllCachedFilesChanged() + } - fun clearKindCache(kind: String): Long = cache.clearKind(kind).also { - if (it > 0L) markAllCachedFilesChanged() - } + fun clearKindCache(kind: String): Long = + cache.clearKind(kind).also { + if (it > 0L) markAllCachedFilesChanged() + } suspend fun ensureLocalMessageMedia( message: Message, @@ -368,15 +426,19 @@ class MediaRepository( suspend fun ensureLocalScheduledMessageMedia( scheduled: ScheduledMessage, priority: Int = MediaPriority.VISIBLE, - ): Outcome = ensureMessageMedia( - scheduled.message.copy(id = scheduled.mediaMessageId), - priority, - retain = false, - scheduled = true - ) + ): Outcome = + ensureMessageMedia( + scheduled.message.copy(id = scheduled.mediaMessageId), + priority, + retain = false, + scheduled = true, + ) private suspend fun ensureMessageMedia( - message: Message, priority: Int, retain: Boolean, scheduled: Boolean + message: Message, + priority: Int, + retain: Boolean, + scheduled: Boolean, ): Outcome { val key = message.mediaCacheKey ?: return Outcome.Err("message has no media key") return ensureLocalTelegramMedia( @@ -397,8 +459,9 @@ class MediaRepository( message: Message, priority: Int = MediaPriority.DEFAULT, ): Outcome { - val key = message.mediaCacheKey?.let { photoDisplayCacheKey(it) } - ?: return Outcome.Err("message has no media key") + val key = + message.mediaCacheKey?.let { photoDisplayCacheKey(it) } + ?: return Outcome.Err("message has no media key") return ensureLocalTelegramMedia( key = key, chatId = message.id.chatId, @@ -417,8 +480,9 @@ class MediaRepository( PerfLog.event("stream_prefix", "priority=$priority") val key = streamMediaKey(message) ?: return Outcome.Err("message has no media key") val fetcher = telegramChunkFetcher ?: return Outcome.Err("stream prefetch unavailable") - val limit = message.fileSize?.takeIf { it > 0 }?.coerceAtMost(STREAM_PREFIX_BYTES) - ?: STREAM_PREFIX_BYTES + val limit = + message.fileSize?.takeIf { it > 0 }?.coerceAtMost(STREAM_PREFIX_BYTES) + ?: STREAM_PREFIX_BYTES if (!streamRoot.isDirectory && !streamRoot.mkdirs()) { return Outcome.Err("stream prefix directory") } @@ -433,9 +497,15 @@ class MediaRepository( } val temporary = File.createTempFile("stream-$offset-", ".part", streamRoot) try { - when (val result = fetcher.fetch( - message.id.chatId, message.id.id, temporary.absolutePath, offset - )) { + when ( + val result = + fetcher.fetch( + message.id.chatId, + message.id.id, + temporary.absolutePath, + offset, + ) + ) { is Outcome.Err -> { temporary.delete() return last?.let { Outcome.Ok(it) } ?: result @@ -444,25 +514,33 @@ 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") } java.io.RandomAccessFile(fetched, "rw").use { it.fd.sync() } - val file = try { - cache.putFile( - streamPartKey(key, offset), - fetched, - message.id.chatId.value, - FileCache.KIND_VIDEOS, - ) - } catch (error: Exception) { - return last?.let { Outcome.Ok(it) } ?: Outcome.Err( - error.message ?: "stream prefix publish", error - ) - } + val file = + try { + cache.putFile( + streamPartKey(key, offset), + fetched, + message.id.chatId.value, + FileCache.KIND_VIDEOS, + ) + } catch (error: Exception) { + return last?.let { Outcome.Ok(it) } ?: Outcome.Err( + error.message ?: "stream prefix publish", + error, + ) + } last = file if (bytes < STREAM_PART) return Outcome.Ok(file) } @@ -485,12 +563,15 @@ class MediaRepository( } private suspend fun prefetchMp4Metadata( - message: Message, key: String, fetcher: TelegramChunkFetcher + message: Message, + key: String, + 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) @@ -503,9 +584,15 @@ class MediaRepository( remaining -= charge val temporary = File.createTempFile("metadata-", ".part", streamRoot) try { - when (val result = fetcher.fetch( - message.id.chatId, message.id.id, temporary.absolutePath, aligned - )) { + when ( + val result = + fetcher.fetch( + message.id.chatId, + message.id.id, + temporary.absolutePath, + aligned, + ) + ) { is Outcome.Err -> return null is Outcome.Ok -> if (File(result.value).canonicalFile != temporary.canonicalFile) return null } @@ -513,7 +600,10 @@ class MediaRepository( java.io.RandomAccessFile(temporary, "rw").use { it.fd.sync() } return runCatching { cache.putFile( - partKey, temporary, message.id.chatId.value, FileCache.KIND_VIDEOS + partKey, + temporary, + message.id.chatId.value, + FileCache.KIND_VIDEOS, ) }.getOrNull() } finally { @@ -527,9 +617,11 @@ class MediaRepository( while (copied < count) { val position = offset + copied val file = part(position) ?: break - val take = minOf( - (count - copied).toLong(), file.length() - (position % STREAM_PART) - ).toInt() + val take = + minOf( + (count - copied).toLong(), + file.length() - (position % STREAM_PART), + ).toInt() if (take <= 0) break java.io.RandomAccessFile(file, "r").use { input -> input.seek(position % STREAM_PART) @@ -543,7 +635,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 } @@ -563,12 +656,17 @@ class MediaRepository( suspend fun ensureLocalScheduledMessageThumb( scheduled: ScheduledMessage, priority: Int = MediaPriority.THUMB, - ): Outcome = ensureMessageThumb( - scheduled.message.copy(id = scheduled.mediaMessageId), priority, scheduled = true - ) + ): Outcome = + ensureMessageThumb( + scheduled.message.copy(id = scheduled.mediaMessageId), + priority, + scheduled = true, + ) private suspend fun ensureMessageThumb( - message: Message, priority: Int, scheduled: Boolean + message: Message, + priority: Int, + scheduled: Boolean, ): Outcome { val distinctThumb = message.thumbCacheKey?.takeUnless { it == message.mediaCacheKey } if (distinctThumb == null && message.mediaKind in NO_SYNTHETIC_THUMB_KINDS) { @@ -576,7 +674,7 @@ class MediaRepository( } val key = distinctThumb ?: message.mediaCacheKey?.let(::mediaThumbCacheKey) ?: return Outcome.Err( - "message has no media key" + "message has no media key", ) return ensureLocalTelegramMedia( key = key, @@ -611,19 +709,24 @@ class MediaRepository( scheduleFullAvatar(peerId, cacheKey) 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) return small } - is Outcome.Err -> Unit + is Outcome.Err -> { + Unit + } } return fetchFullAvatar(peerId, cacheKey, priority) } @@ -632,35 +735,46 @@ class MediaRepository( peerId: PeerId, cacheKey: String, priority: Int, - ): Outcome = when (val fetched = ensureLocalTelegramMedia( - key = cacheKey, - chatId = peerId, - messageId = 0, - kind = MediaFetchKind.Full, - priority = priority, - )) { - is Outcome.Ok -> { - rememberPeerAvatar(peerId, fetched.value) - fetched - } + ): 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 + is Outcome.Err -> { + if (isVideoAvatarKey(cacheKey)) { + fetched + } else { + cachedAvatar(peerId)?.let { Outcome.Ok(it) } ?: fetched + } + } } - } - private fun scheduleFullAvatar(peerId: PeerId, cacheKey: String) { + private fun scheduleFullAvatar( + peerId: PeerId, + cacheKey: String, + ) { if (isVideoAvatarKey(cacheKey) || cache.get(cacheKey) != null) return scope.launch { if (cache.get(cacheKey) != null) return@launch - val fetched = ensureLocalTelegramMedia( - key = cacheKey, - chatId = peerId, - messageId = 0, - kind = MediaFetchKind.Full, - priority = MediaPriority.IDLE, - ) + val fetched = + ensureLocalTelegramMedia( + key = cacheKey, + chatId = peerId, + messageId = 0, + kind = MediaFetchKind.Full, + priority = MediaPriority.IDLE, + ) if (fetched is Outcome.Ok) rememberPeerAvatar(peerId, fetched.value) } } @@ -669,12 +783,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) } @@ -687,15 +807,16 @@ class MediaRepository( priority: Int = MediaPriority.DEFAULT, mediaKind: String? = null, consumer: MediaConsumer? = null, - ): Outcome = ensureLocalTelegramMedia( - key = cacheKey, - chatId = peerId, - messageId = messageId, - kind = if (thumb) MediaFetchKind.Thumb else MediaFetchKind.Full, - mediaKind = mediaKind, - priority = priority, - consumer = consumer, - ) + ): Outcome = + ensureLocalTelegramMedia( + key = cacheKey, + chatId = peerId, + messageId = messageId, + kind = if (thumb) MediaFetchKind.Thumb else MediaFetchKind.Full, + mediaKind = mediaKind, + priority = priority, + consumer = consumer, + ) suspend fun ensureCustomEmoji( documentId: Long, @@ -708,8 +829,9 @@ class MediaRepository( 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") + 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) @@ -733,7 +855,9 @@ class MediaRepository( } } - is Outcome.Err -> fetched + is Outcome.Err -> { + fetched + } } } if (result is Outcome.Err) { @@ -772,86 +896,99 @@ class MediaRepository( } PerfLog.event("cache_miss", kind.name.lowercase()) val queuedAt = PerfLog.nowMs() - val fetched = enqueueTelegram( - key, - priority, - chatId = chatId.value, - name = name, - totalBytes = totalBytes, - large = kind != MediaFetchKind.Thumb && isLargeDownload(totalBytes), - thumbnail = kind == MediaFetchKind.Thumb, - consumer = consumer, - ) { activePriority -> - val waited = PerfLog.nowMs() - queuedAt - PerfLog.mark("queue_wait:${kind.name.lowercase()}", waited) - cache.get(key)?.let { return@enqueueTelegram Outcome.Ok(it) } - val dest = cache.fileFor(key) - val part = File(dest.parentFile, "${dest.name}.part") - if (isCancelled(key)) { - return@enqueueTelegram Outcome.Err("cancelled") - } - setProgress(key, maxOf(progressBytes(key), part.length())) - // The native side pipelines parts on one session; this is a cache wrapper. - val fetcher = telegramFetcher - ?: return@enqueueTelegram Outcome.Err("telegram media fetcher not configured") - val destPath = part.absolutePath - progressPaths[destPath] = key - val reservation = totalBytes?.takeIf { it > 0L } - ?.let { runCatching { cache.reserve(key, it, optional = false) }.getOrNull() } - val fetched = try { - fetcher.fetchMessageMedia( - chatId = chatId, - messageId = messageId, - destPath = destPath, - kind = kind, - priority = activePriority, - ) - } finally { - progressPaths.remove(destPath) - cancelMarker(key).delete() - cache.notePartial(key, part.length()) - reservation?.close() - } - when (fetched) { - is Outcome.Ok -> { - val file = File(fetched.value) - if (isCancelled(key)) { - part.delete() - file.takeIf { it != part && it.exists() }?.delete() - clearProgress(key) - Outcome.Err("cancelled") - } else if (!file.exists()) { - part.delete() - clearProgress(key) - Outcome.Err("media download produced no file") - } else { - clearProgress(key) - publishDownloadedFile( - key = key, - dest = dest, - source = file, - chatId = storedChatId, - mediaKind = mediaKind, + val fetched = + enqueueTelegram( + key, + priority, + chatId = chatId.value, + name = name, + totalBytes = totalBytes, + large = kind != MediaFetchKind.Thumb && isLargeDownload(totalBytes), + thumbnail = kind == MediaFetchKind.Thumb, + consumer = consumer, + ) { activePriority -> + val waited = PerfLog.nowMs() - queuedAt + PerfLog.mark("queue_wait:${kind.name.lowercase()}", waited) + cache.get(key)?.let { return@enqueueTelegram Outcome.Ok(it) } + val dest = cache.fileFor(key) + val part = File(dest.parentFile, "${dest.name}.part") + if (isCancelled(key)) { + return@enqueueTelegram Outcome.Err("cancelled") + } + setProgress(key, maxOf(progressBytes(key), part.length())) + // The native side pipelines parts on one session; this is a cache wrapper. + val fetcher = + telegramFetcher + ?: return@enqueueTelegram Outcome.Err("telegram media fetcher not configured") + val destPath = part.absolutePath + progressPaths[destPath] = key + val reservation = + totalBytes + ?.takeIf { it > 0L } + ?.let { + runCatching { + cache.reserve( + key, + it, + optional = false + ) + }.getOrNull() + } + val fetched = + try { + fetcher.fetchMessageMedia( + chatId = chatId, + messageId = messageId, + destPath = destPath, + kind = kind, + priority = activePriority, ) + } finally { + progressPaths.remove(destPath) + cancelMarker(key).delete() + cache.notePartial(key, part.length()) + reservation?.close() + } + when (fetched) { + is Outcome.Ok -> { + val file = File(fetched.value) + if (isCancelled(key)) { + part.delete() + file.takeIf { it != part && it.exists() }?.delete() + clearProgress(key) + Outcome.Err("cancelled") + } else if (!file.exists()) { + part.delete() + clearProgress(key) + Outcome.Err("media download produced no file") + } else { + clearProgress(key) + publishDownloadedFile( + key = key, + dest = dest, + source = file, + chatId = storedChatId, + mediaKind = mediaKind, + ) + } } - } - 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 - if (!keepPartial) { - part.delete() - clearProgress(key) + 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 + if (!keepPartial) { + part.delete() + clearProgress(key) + } + fetched } - fetched } } - } if (!retain || fetched !is Outcome.Ok) return fetched return Outcome.Ok(keptDownload(key) ?: fetched.value) } @@ -884,13 +1021,15 @@ class MediaRepository( belowPriority: Int = MediaPriority.USER, keepKeys: Set = emptySet(), ) { - val keys = telegramMutex.withLock { - val matched = telegramJobs.values.filter { job -> - job.chatId == chatId.value && job.priority < belowPriority && job.key !in keepKeys && !job.deferred.isCompleted + val keys = + telegramMutex.withLock { + val matched = + telegramJobs.values.filter { job -> + job.chatId == chatId.value && job.priority < belowPriority && job.key !in keepKeys && !job.deferred.isCompleted + } + matched.forEach { cancelJobLocked(it) } + matched.map { it.key } } - matched.forEach { cancelJobLocked(it) } - matched.map { it.key } - } if (keys.isNotEmpty()) PerfLog.event("cancel_chat", keys.size.toString()) } @@ -907,16 +1046,22 @@ class MediaRepository( private suspend fun cancelKey(key: String) { telegramMutex.withLock { - val job = telegramJobs[key] ?: run { - markCancelled(key) - return@withLock - } + val job = + telegramJobs[key] ?: run { + markCancelled(key) + return@withLock + } cancelJobLocked(job) } } private fun markCancelled(key: String) { - runCatching { cancelMarker(key).apply { parentFile?.mkdirs(); writeText("") } } + runCatching { + cancelMarker(key).apply { + parentFile?.mkdirs() + writeText("") + } + } } private fun cancelJobLocked(job: TelegramJob) { @@ -940,10 +1085,15 @@ class MediaRepository( telegramPending += QueuedTelegram(job) } - private fun hasPendingAtLeastLocked(minPriority: Int, thumbnail: Boolean? = null): Boolean = + 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) + queued.generation == job.generation && !job.running && !job.cancelled && !job.deferred.isCompleted && + job.priority >= minPriority && + (thumbnail == null || job.thumbnail == thumbnail) } private suspend fun applyDownloadConsumerPriority( @@ -990,7 +1140,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-- @@ -1042,74 +1195,86 @@ class MediaRepository( ): Outcome { cache.get(key)?.let { return Outcome.Ok(it) } val bound = consumer?.takeIf { it.key == key } - val job = telegramMutex.withLock { - check(!telegramStopped) { "download queue is shut down" } - val existing = telegramJobs[key] - val attached = if (existing != null) { - if (existing.chatId == null && chatId != null) existing.chatId = chatId - if (large) existing.large = true - if (bound == null) { - if (priority > existing.priority) { - existing.priority = priority - if (!existing.cancelled && !existing.running) { - existing.generation += 1 - existing.sequence = telegramSequence++ - telegramPending += QueuedTelegram(existing) - } else if (existing.running && !existing.cancelled) { - val dest = cache.fileFor(key) - telegramFetcher?.promote( - File( - dest.parentFile, "${dest.name}.part" - ).absolutePath, priority + val job = + telegramMutex.withLock { + check(!telegramStopped) { "download queue is shut down" } + val existing = telegramJobs[key] + val attached = + if (existing != null) { + if (existing.chatId == null && chatId != null) existing.chatId = chatId + if (large) existing.large = true + if (bound == null) { + if (priority > existing.priority) { + existing.priority = priority + if (!existing.cancelled && !existing.running) { + existing.generation += 1 + existing.sequence = telegramSequence++ + telegramPending += QueuedTelegram(existing) + } else if (existing.running && !existing.cancelled) { + val dest = cache.fileFor(key) + telegramFetcher?.promote( + File( + dest.parentFile, + "${dest.name}.part", + ).absolutePath, + priority, + ) + } + } + if (priority >= MediaPriority.USER) { + trackUserDownloadLocked(key, name, totalBytes) + } + existing.anonymousConsumers++ + existing.consumers++ + WorkOwnership.acquire("media:${existing.key}", existing.priority) + } else { + attachSlotLocked(existing, bound, priority, name, totalBytes) + } + existing + } else { + cancelMarker(key).delete() + val created = + TelegramJob( + key = key, + chatId = chatId, + priority = priority, + sequence = telegramSequence++, + large = large, + thumbnail = thumbnail, + work = work, ) + telegramJobs[key] = created + telegramPending += QueuedTelegram(created) + if (bound == null) { + if (priority >= MediaPriority.USER) { + trackUserDownloadLocked(key, name, totalBytes) + } + created.anonymousConsumers++ + created.consumers++ + WorkOwnership.acquire("media:${created.key}", created.priority) + } else { + attachSlotLocked(created, bound, priority, name, totalBytes) } + created } - if (priority >= MediaPriority.USER) { - trackUserDownloadLocked(key, name, totalBytes) - } - existing.anonymousConsumers++ - existing.consumers++ - WorkOwnership.acquire("media:${existing.key}", existing.priority) - } else { - attachSlotLocked(existing, bound, priority, name, totalBytes) - } - existing - } else { - cancelMarker(key).delete() - val created = TelegramJob( - key = key, - chatId = chatId, - priority = priority, - sequence = telegramSequence++, - large = large, - thumbnail = thumbnail, - work = work, - ) - telegramJobs[key] = created - telegramPending += QueuedTelegram(created) - if (bound == null) { - if (priority >= MediaPriority.USER) { - trackUserDownloadLocked(key, name, totalBytes) - } - created.anonymousConsumers++ - created.consumers++ - WorkOwnership.acquire("media:${created.key}", created.priority) - } else { - attachSlotLocked(created, bound, priority, name, totalBytes) - } - created + ensureTelegramWorkerLocked() + attached } - ensureTelegramWorkerLocked() - attached - } telegramWake.trySend(Unit) thumbWake.trySend(Unit) try { 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() @@ -1140,11 +1305,12 @@ class MediaRepository( val want = fullWorkers + THUMB_WORKERS while (telegramWorkers.size < want) { val thumbnail = telegramWorkers.size >= fullWorkers - telegramWorkers += scope.launch(Dispatchers.IO) { - while (isActive && !telegramStopped) { - takeNextTelegram(thumbnail)?.let { runTelegram(it) } + telegramWorkers += + scope.launch(Dispatchers.IO) { + while (isActive && !telegramStopped) { + takeNextTelegram(thumbnail)?.let { runTelegram(it) } + } } - } } } @@ -1167,13 +1333,14 @@ class MediaRepository( val job = queued.job if (queued.generation != job.generation) continue if (job.cancelled || job.running || job.deferred.isCompleted) continue - val hold = when { - job.thumbnail != thumbnail -> true - job.large && runningLargeLocked() >= MAX_LARGE_PIPELINES -> true - holdDefault && job.priority < MediaPriority.DISPLAY -> true - holdIdle && job.priority <= MediaPriority.IDLE -> true - else -> false - } + val hold = + when { + job.thumbnail != thumbnail -> true + job.large && runningLargeLocked() >= MAX_LARGE_PIPELINES -> true + holdDefault && job.priority < MediaPriority.DISPLAY -> true + holdIdle && job.priority <= MediaPriority.IDLE -> true + else -> false + } if (hold) { skipped += queued continue @@ -1195,35 +1362,39 @@ class MediaRepository( } return } - 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) } - job.runner = runner - try { - runner.start() - runner.await() - } finally { - job.runner = null + 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) } + job.runner = runner + try { + runner.start() + runner.await() + } finally { + job.runner = null + } } + } catch (e: CancellationException) { + if (!currentCoroutineContext().isActive) throw e + Outcome.Err("cancelled") + } catch (e: Exception) { + Outcome.Err(e.message ?: "media download failed", e) } - } catch (e: CancellationException) { - if (!currentCoroutineContext().isActive) throw e - Outcome.Err("cancelled") - } catch (e: Exception) { - Outcome.Err(e.message ?: "media download failed", e) } - } - val requeued = telegramMutex.withLock { - if (job.preempted && !job.cancelled && result !is Outcome.Ok) { - requeuePreemptedLocked(job) - true - } else { - false + val requeued = + telegramMutex.withLock { + if (job.preempted && !job.cancelled && result !is Outcome.Ok) { + requeuePreemptedLocked(job) + true + } else { + false + } } - } if (requeued) { AppLog.api("media", "requeue key=${job.key} pri=${job.priority}") telegramWake.trySend(Unit) @@ -1254,15 +1425,20 @@ class MediaRepository( 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 { - source.inputStream().use { input -> - dest.outputStream().use { output -> input.copyTo(output) } - } - }.isSuccess + val copied = + runCatching { + source.inputStream().use { input -> + dest.outputStream().use { output -> input.copyTo(output) } + } + }.isSuccess return if (copied) dest else source } - 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)) @@ -1290,8 +1466,8 @@ class MediaRepository( source: File, chatId: Long?, mediaKind: String?, - ): Outcome { - return runCatching { + ): Outcome = + runCatching { cache.putFile( key, source, @@ -1306,19 +1482,26 @@ class MediaRepository( }, 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) } @@ -1330,7 +1513,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, @@ -1381,7 +1567,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/test/java/org/monogram/network/http/MediaRepositoryThumbTest.kt b/network/http/src/test/java/org/monogram/network/http/MediaRepositoryThumbTest.kt index 330f46cd7..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 @@ -26,251 +27,396 @@ class MediaRepositoryThumbTest { val tmp = TemporaryFolder() @Test - 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") + 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)) - } + 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 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 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 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 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")) - } + 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) @@ -282,20 +428,22 @@ class MediaRepositoryThumbTest { @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", - ) + 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 From c9c341a4cdf2952552dc090d47e0896bd844937e Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:36:44 +0300 Subject: [PATCH 6/9] feat(account): redesign multiaccount selection --- app/src/main/java/org/monogram/MonogramApp.kt | 1012 ++++++++++------- .../core/ui/components/SettingsTile.kt | 169 ++- .../java/org/monogram/core/ui/menu/AppMenu.kt | 251 ++-- .../monogram/feature/chats/AccountMenuItem.kt | 6 +- .../feature/chats/ui/ChatListAppBar.kt | 151 ++- .../feature/settings/ui/SettingsHome.kt | 68 +- 6 files changed, 968 insertions(+), 689 deletions(-) diff --git a/app/src/main/java/org/monogram/MonogramApp.kt b/app/src/main/java/org/monogram/MonogramApp.kt index 12c5058f5..2d07d50e6 100644 --- a/app/src/main/java/org/monogram/MonogramApp.kt +++ b/app/src/main/java/org/monogram/MonogramApp.kt @@ -122,20 +122,24 @@ class MonogramApp : Application() { 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 - ) - }, - ) + 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 @@ -151,25 +155,37 @@ class MonogramApp : Application() { ) 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 + 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() - } + 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() { @@ -212,18 +228,26 @@ class MonogramApp : Application() { return accountLiveLocations.getOrPut(client) { LiveLocationRuntime(activityScope, warmup, sample = { currentLocation( - this, allowCached = false - )?.let { org.monogram.core.models.GeoPlace(it.first, it.second) } + this, + allowCached = false, + )?.let { + org.monogram.core.models + .GeoPlace(it.first, it.second) + } }, permitted = { ContextCompat.checkSelfPermission( - this, permission.ACCESS_COARSE_LOCATION + this, + permission.ACCESS_COARSE_LOCATION, ) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission( - this, permission.ACCESS_FINE_LOCATION + this, + permission.ACCESS_FINE_LOCATION, ) == PackageManager.PERMISSION_GRANTED }, edit = { id, point -> val epoch = warmup.outgoingAccountEpoch() editRetainedLiveLocation( - client, id, point + client, + id, + point, ) { warmup.outgoingAccountIsCurrent(epoch) } }) } @@ -244,13 +268,14 @@ class MonogramApp : Application() { private fun updateActivityForeground() { activityIdleJob?.cancel() - activityIdleJob = activityScope.launch { - awaitReady() - val foreground = startedActivities.isNotEmpty() - val activeUserId = accountRegistry.activeUserId - if (activeUserId == null) push.setForeground(foreground) - pushRouter.setForeground(foreground, activeUserId) - } + activityIdleJob = + activityScope.launch { + awaitReady() + val foreground = startedActivities.isNotEmpty() + val activeUserId = accountRegistry.activeUserId + if (activeUserId == null) push.setForeground(foreground) + pushRouter.setForeground(foreground, activeUserId) + } } private suspend fun startAccountPushes(readyUserIds: Set? = null) { @@ -261,23 +286,27 @@ class MonogramApp : Application() { 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 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, - ) + 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() @@ -342,17 +371,20 @@ class MonogramApp : Application() { 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(), - ) + 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, @@ -387,30 +419,34 @@ class MonogramApp : Application() { } } - 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) + 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) } - 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?, @@ -424,92 +460,103 @@ class MonogramApp : Application() { 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" } + 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, + ) } - 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 { - 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 switchAccount(userId: Long) = + accountOperations.withLock { + 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 -> - 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") + 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 -> + 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) } - applyDownloadSettings(nextRuntime.client, DownloadSettings.state.value) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("account", "remaining account connection failed unexpectedly") } - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - AppLog.warn("account", "remaining account connection failed unexpectedly") } + startAccountPushes() } - startAccountPushes() - } private fun bindActiveRuntime(runtime: AccountRuntime) { client = runtime.client mediaRepository = accountMedia(runtime) - notifications = NotificationLocalStore( - this, - runtime.account.userId, - migrateLegacy = runtime.account.databaseName == "monogram.db", - ) + notifications = + NotificationLocalStore( + this, + runtime.account.userId, + migrateLegacy = runtime.account.databaseName == "monogram.db", + ) sessionStore = runtime.sessionStore warmup = runtime.warmup push = accountPushes[runtime.account.userId] ?: push @@ -517,46 +564,53 @@ class MonogramApp : Application() { 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 title = - runtime.sessionStore.readProfile(account.userId)?.title?.takeIf { it.isNotBlank() } - ?: getString( - org.monogram.feature.chats.R.string.chats_account_fallback, 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, - ) - } 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, - ) + 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)) - } + 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() }, @@ -564,29 +618,40 @@ class MonogramApp : Application() { 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 + 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, - ) - } + 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) } @@ -602,74 +667,106 @@ class MonogramApp : Application() { suspend fun awaitReady() = ready.await() - fun handleFcm(payload: String?, onComplete: (() -> Unit)? = null) { + fun handleFcm( + payload: String?, + onComplete: (() -> Unit)? = null, + ) { pushRouter.handleFcm(payload, onComplete) } - fun awaitReadyBlocking(timeoutMs: Long = 20_000L): Boolean = runBlocking { - try { - withTimeout(timeoutMs) { ready.await() } - true - } catch (_: Exception) { - false + fun awaitReadyBlocking(timeoutMs: Long = 20_000L): Boolean = + runBlocking { + try { + withTimeout(timeoutMs) { ready.await() } + true + } catch (_: Exception) { + false + } } - } - fun awaitPushReadyBlocking(timeoutMs: Long = 20_000L): Boolean = runBlocking { - try { - withTimeout(timeoutMs) { pushReady.await() } - true - } catch (_: Exception) { - false + 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() - } + 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 = TelegramChunkFetcher { chatId, messageId, path, offset -> - client.downloadMessageMediaChunk(chatId, messageId, path, offset) - }, - telegramFetcher = object : TelegramMediaFetcher { - override fun promote(destPath: String, priority: Int) = - client.promoteDownload(destPath, priority) + private fun createMediaRepository( + client: BridgedMtprotoClient, + root: File, + ): MediaRepository { + val repository = + MediaRepository( + cacheRoot = root, + telegramChunkFetcher = + TelegramChunkFetcher { chatId, messageId, path, offset -> + client.downloadMessageMediaChunk(chatId, messageId, path, 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 - ) + 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.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, - ) + 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) } @@ -696,7 +793,10 @@ class MonogramApp : Application() { } } - private fun applyDownloadSettings(client: BridgedMtprotoClient, state: DownloadState) { + private fun applyDownloadSettings( + client: BridgedMtprotoClient, + state: DownloadState, + ) { client.applyDownloadConcurrency(state.effectiveLanes, state.parts) client.setFilePartKib(state.filePartKib) client.setDownloadChunkKib(state.downloadChunkKib) @@ -755,17 +855,20 @@ class MonogramApp : Application() { } private fun firstPaintSteps(): StartupSteps { - credentials = TelegramCredentials( - apiId = BuildConfig.TELEGRAM_API_ID, - apiHash = BuildConfig.TELEGRAM_API_HASH, - ) + 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 currentAccount(): AccountRecord? = + if (::accountRegistry.isInitialized) { + accountRegistry.activeUserId?.let(accountRegistry::get) + } else { + null + } fun currentMediaRoot(): File = currentAccount()?.mediaPath ?: durableMedia lateinit var db: MonogramDatabase @@ -774,18 +877,22 @@ class MonogramApp : Application() { openDatabase = { 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) + db = + perfSpan("app:database") { + if (account == null || account.databaseName == "monogram.db") { + DatabaseProvider.get(this) + } else { + DatabaseProvider.get(this, account.userId) + } } - } - warmup = OfflineWarmup( - db, outgoingMediaFiles = OutgoingMediaFiles( - File(noBackupFilesDir, "outgoing-media") + warmup = + OfflineWarmup( + db, + outgoingMediaFiles = + OutgoingMediaFiles( + File(noBackupFilesDir, "outgoing-media"), + ), ) - ) sessionStore = SessionMetadataStore(db) }, installAppearance = { @@ -805,15 +912,17 @@ class MonogramApp : Application() { }.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) - } + accountRuntimeManager = + AccountRuntimeManager( + context = this, + registry = accountRegistry, + credentials = credentials, + initConnection = monogramInitConnection(), + ) + val runtime = + currentAccount()?.let { + accountRuntimeManager.switchTo(it.userId) + } client = runtime?.client ?: perfSpan("app:client") { BridgedMtprotoClient( credentials = credentials, @@ -826,23 +935,29 @@ class MonogramApp : Application() { 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 - }.getOrDefault(false) + 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 + } + }.getOrDefault(false) if (!applied) { runCatching { client.clearProxy() } runCatching { client.setTransportMode(MtprotoTransportMode.PADDED_INTERMEDIATE) } @@ -893,27 +1008,42 @@ class MonogramApp : Application() { 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) val durableMedia = currentMediaRoot() - if ((currentAccount() == null || currentAccount()?.databaseName == "monogram.db") && !durableMedia.exists() && cacheMedia.exists()) { + if ((currentAccount() == null || currentAccount()?.databaseName == "monogram.db") && !durableMedia.exists() && + cacheMedia.exists() + ) { runCatching { cacheMedia.copyRecursively( - durableMedia, overwrite = false + durableMedia, + overwrite = false, ) }.onFailure { AppLog.warn("media", "cache migration failed") } } @@ -929,9 +1059,10 @@ class MonogramApp : Application() { val durableMedia = currentMediaRoot() val client = client val warmup = warmup - mediaRepository = perfSpan("app:mediaRepository") { - createMediaRepository(client, durableMedia) - } + mediaRepository = + perfSpan("app:mediaRepository") { + createMediaRepository(client, durableMedia) + } val accountId = currentAccount()?.userId if (accountId != null) { accountMediaRepositories[accountId] = mediaRepository @@ -951,26 +1082,28 @@ class MonogramApp : Application() { } }, createPush = { - notifications = NotificationLocalStore( - this, - currentAccount()?.userId, - migrateLegacy = currentAccount()?.databaseName == "monogram.db", - ) - push = perfSpan("app:push") { - PushCoordinator( + notifications = + NotificationLocalStore( this, - client, - notifications, - mediaRepository, - DefaultStoreFactory(), - sessionStore, - otherAccountUserIds = { - accountRegistry.all().map { it.userId } - }, - sharedPushSecret = sharedPushSecret::get, - notificationCache = warmup, + currentAccount()?.userId, + migrateLegacy = currentAccount()?.databaseName == "monogram.db", ) - } + push = + perfSpan("app:push") { + PushCoordinator( + this, + client, + notifications, + mediaRepository, + DefaultStoreFactory(), + sessionStore, + otherAccountUserIds = { + accountRegistry.all().map { it.userId } + }, + sharedPushSecret = sharedPushSecret::get, + notificationCache = warmup, + ) + } pushRouter = AccountPushRouter() currentAccount()?.userId?.let { userId -> push.setAccountUserId(userId) @@ -978,81 +1111,87 @@ class MonogramApp : Application() { accountPushes[userId] = push pushRouterCoordinatorIds += userId } - sponsorSync = SponsorSyncManager( - settingsScope, - client, - db.sponsorDao(), - sessionStore, - ) - appUpdate = AppUpdateManager( - scope = settingsScope, - isAuthorized = { - resolveStartOnHome( - sessionAuthorized = { sessionStore.isAuthorized() }, - locallyAuthorized = { client.isLocallyAuthorized() }, - explicitlySignedOut = { - sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0" - }, - ) - }, - betaUpdates = { AppUpdateSettings.betaUpdates.value }, - isSupporter = { - val id = sessionStore.readAuthorizedUserId()?.value - id != null && id in SponsorRegistry.sponsorIds.value - }, - resolveUsername = { username -> - when (val outcome = client.resolveUsername(username)) { - is Outcome.Ok -> Outcome.Ok(outcome.value.peerId.value) - is Outcome.Err -> outcome - } - }, - getHistoryPage = { chatId, limit, offsetId -> - client.getHistoryPage(PeerId(chatId), limit, offsetId) - }, - telegramDownload = { message -> - mediaRepository.ensureLocalMessageMedia( - message, - MediaPriority.USER, - ) - }, - cancelDownloadKey = { key -> mediaRepository.cancel(key) }, - progress = mediaRepository.downloadProgress, - currentVersionCode = { BuildConfig.VERSION_CODE }, - currentCommit = { BuildConfig.GIT_COMMIT }, - buildType = BuildConfig.BUILD_TYPE, - supportedAbis = { Build.SUPPORTED_ABIS.toList() }, - installer = AndroidAppUpdateInstaller(this), - updateDir = File(cacheDir, "updates"), - ) + sponsorSync = + SponsorSyncManager( + settingsScope, + client, + db.sponsorDao(), + sessionStore, + ) + appUpdate = + AppUpdateManager( + scope = settingsScope, + isAuthorized = { + resolveStartOnHome( + sessionAuthorized = { sessionStore.isAuthorized() }, + locallyAuthorized = { client.isLocallyAuthorized() }, + explicitlySignedOut = { + sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0" + }, + ) + }, + betaUpdates = { AppUpdateSettings.betaUpdates.value }, + isSupporter = { + val id = sessionStore.readAuthorizedUserId()?.value + id != null && id in SponsorRegistry.sponsorIds.value + }, + resolveUsername = { username -> + when (val outcome = client.resolveUsername(username)) { + is Outcome.Ok -> Outcome.Ok(outcome.value.peerId.value) + is Outcome.Err -> outcome + } + }, + getHistoryPage = { chatId, limit, offsetId -> + client.getHistoryPage(PeerId(chatId), limit, offsetId) + }, + telegramDownload = { message -> + mediaRepository.ensureLocalMessageMedia( + message, + MediaPriority.USER, + ) + }, + cancelDownloadKey = { key -> mediaRepository.cancel(key) }, + progress = mediaRepository.downloadProgress, + currentVersionCode = { BuildConfig.VERSION_CODE }, + currentCommit = { BuildConfig.GIT_COMMIT }, + buildType = BuildConfig.BUILD_TYPE, + supportedAbis = { Build.SUPPORTED_ABIS.toList() }, + installer = AndroidAppUpdateInstaller(this), + updateDir = File(cacheDir, "updates"), + ) }, startPush = { runCatching { push.start() }.onFailure { AppLog.warn( - "startup", "push start failed" + "startup", + "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, - ) + 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 } - startAccountPushes(ready) - pushReady.complete(Unit) - ready - } accountRuntimeManager.startAll(readyUserIds) accountOperations.withLock { for (runtime in accountRuntimeManager.all()) { @@ -1081,14 +1220,16 @@ class MonogramApp : Application() { startSponsor = { runCatching { sponsorSync.start() }.onFailure { AppLog.warn( - "startup", "sponsor start failed" + "startup", + "sponsor start failed", ) } }, startAppUpdate = { runCatching { appUpdate.start() }.onFailure { AppLog.warn( - "startup", "app update start failed" + "startup", + "app update start failed", ) } }, @@ -1099,9 +1240,10 @@ class MonogramApp : Application() { val locale = Locale.getDefault() return ClientInitInfo( deviceModel = initDeviceModel(), - systemVersion = Build.VERSION.RELEASE.orEmpty().ifBlank { - "SDK ${Build.VERSION.SDK_INT}" - }, + systemVersion = + Build.VERSION.RELEASE.orEmpty().ifBlank { + "SDK ${Build.VERSION.SDK_INT}" + }, appVersion = BuildConfig.VERSION_NAME.ifBlank { "0" }, systemLangCode = locale.language.ifBlank { "en" }, langPack = "android", @@ -1135,13 +1277,21 @@ private fun isEmulatorDevice(model: String): Boolean { val fingerprint = Build.FINGERPRINT.orEmpty() val product = Build.PRODUCT.orEmpty() val hardware = Build.HARDWARE.orEmpty() - return fingerprint.startsWith("generic") || fingerprint.contains( - "emulator", ignoreCase = true - ) || product.contains("sdk", ignoreCase = true) || hardware.contains( - "goldfish", ignoreCase = true - ) || hardware.contains("ranchu", ignoreCase = true) || model.contains( - "sdk_gphone", ignoreCase = true - ) || model.contains("emulator", ignoreCase = true) || model.contains( - "google_sdk", ignoreCase = true - ) + return fingerprint.startsWith("generic") || + fingerprint.contains( + "emulator", + ignoreCase = true, + ) || product.contains("sdk", ignoreCase = true) || + hardware.contains( + "goldfish", + ignoreCase = true, + ) || hardware.contains("ranchu", ignoreCase = true) || + model.contains( + "sdk_gphone", + ignoreCase = true, + ) || model.contains("emulator", ignoreCase = true) || + model.contains( + "google_sdk", + ignoreCase = true, + ) } 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 c9f35a921..156ea3414 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,56 +55,97 @@ 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, - ) + val shape = + when (position) { + ItemPosition.TOP -> { + RoundedCornerShape( + topStart = SettingsGroupCorner, + topEnd = SettingsGroupCorner, + ) + } - ItemPosition.MIDDLE -> RoundedCornerShape(0.dp) - ItemPosition.BOTTOM -> RoundedCornerShape( - bottomStart = SettingsGroupCorner, - bottomEnd = SettingsGroupCorner, - ) + ItemPosition.MIDDLE -> { + RoundedCornerShape(0.dp) + } - ItemPosition.STANDALONE -> RoundedCornerShape(SettingsGroupCorner) - } + ItemPosition.BOTTOM -> { + RoundedCornerShape( + bottomStart = SettingsGroupCorner, + bottomEnd = SettingsGroupCorner, + ) + } + + ItemPosition.STANDALONE -> { + RoundedCornerShape(SettingsGroupCorner) + } + } val container by animateColorAsState( - targetValue = if (selected == true) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerLow - }, + targetValue = + if (selected == true) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerLow + }, label = "settingsCardContainer", ) - val contentColor = if (selected == true) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - } + val contentColor = + if (selected == true) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } Column(modifier = modifier.fillMaxWidth()) { if (position == ItemPosition.MIDDLE || position == ItemPosition.BOTTOM) { Box( - modifier = Modifier - .padding(start = DividerInset) - .fillMaxWidth() - .height(1.dp) - .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = + Modifier + .padding(start = DividerInset) + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), ) } Surface( color = container, contentColor = contentColor, shape = shape, - modifier = Modifier - .fillMaxWidth() - .clip(shape) - .alpha(if (enabled) 1f else 0.5f) - .then( - if (onClick != null && enabled) Modifier.clickable(onClick = onClick) else Modifier, - ), + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .alpha(if (enabled) 1f else 0.5f) + .then( + 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) { Column(content = content) @@ -129,32 +173,42 @@ fun SettingsCardRow( 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, - ) + 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 - } + onClick != null -> { + Modifier.clickable(enabled = enabled, onClick = onClick) + } + + else -> { + Modifier + } + } Row( - modifier = modifier - .fillMaxWidth() - .alpha(if (enabled) 1f else 0.5f) - .then(interaction) - .padding(horizontal = RowHorizontalPadding, vertical = RowVerticalPadding), + modifier = + modifier + .fillMaxWidth() + .alpha(if (enabled) 1f else 0.5f) + .then(interaction) + .padding(horizontal = RowHorizontalPadding, vertical = RowVerticalPadding), verticalAlignment = Alignment.CenterVertically, ) { if (leadingContent != null) { leadingContent() } else { Box( - modifier = Modifier - .size(RowIconSize) - .background(color = iconColor.copy(alpha = 0.16f), shape = CircleShape), + modifier = + Modifier + .size(RowIconSize) + .background(color = iconColor.copy(alpha = 0.16f), shape = CircleShape), contentAlignment = Alignment.Center, ) { Icon( @@ -188,9 +242,10 @@ fun SettingsCardRow( Icon( imageVector = Icons.Outlined.Check, contentDescription = null, - modifier = Modifier - .size(20.dp) - .alpha(if (selected) 1f else 0f), + modifier = + Modifier + .size(20.dp) + .alpha(if (selected) 1f else 0f), ) } } @@ -209,12 +264,16 @@ fun SettingsTile( 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, 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/feature/chats/src/main/java/org/monogram/feature/chats/AccountMenuItem.kt b/feature/chats/src/main/java/org/monogram/feature/chats/AccountMenuItem.kt index 6a452a0ec..2062a4e9d 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/AccountMenuItem.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/AccountMenuItem.kt @@ -1,9 +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, -) \ No newline at end of file + val avatarCacheKey: String? = null, + val mediaRepository: MediaRepository? = null, +) 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 ee03ccc5c..e1a43cf98 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,13 +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.AccountCircle import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.CloudDone import androidx.compose.material.icons.outlined.CloudOff @@ -48,11 +48,19 @@ 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 @@ -104,11 +112,12 @@ internal fun ChatsTopBar( ) { // 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", ) @@ -138,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) } @@ -180,10 +194,11 @@ internal fun ChatsTopBar( addAccountLabel = addAccountLabel, ) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = containerColor, - scrolledContainerColor = containerColor, - ), + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + scrolledContainerColor = containerColor, + ), ) } @@ -286,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) } + 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, @@ -308,15 +353,16 @@ private fun AccountAvatar( 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)) { @@ -336,29 +382,46 @@ private fun AccountAvatar( onDismiss = { menuOpen = false }, alignToAnchorEnd = true, ) { - AppMenuSurface { + AppMenuSurface(scrollState = rememberScrollState()) { AppMenuGroup { - AppMenuItem( + Text( text = accountMenuLabel, - onClick = {}, - enabled = false, + 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 + 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) + } }, - enabled = !account.active && !account.unavailable, - icon = if (account.active) Icons.Outlined.Check else Icons.Outlined.AccountCircle, onClick = { menuOpen = false - onSwitchAccount(account.userId) + if (!account.active) onSwitchAccount(account.userId) }, ) } + } + AppMenuDivider() + AppMenuGroup { AppMenuItem( text = addAccountLabel, icon = Icons.Outlined.Add, @@ -367,8 +430,6 @@ private fun AccountAvatar( onAddAccount() }, ) - } - AppMenuGroup { AppMenuItem( text = profileLabel, icon = Icons.Outlined.Person, 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 65cf3812c..1b3f545d6 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 @@ -1,12 +1,8 @@ package org.monogram.feature.settings.ui import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope @@ -18,11 +14,9 @@ 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.Check import androidx.compose.material.icons.outlined.Devices import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.Folder -import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Science @@ -30,10 +24,7 @@ import androidx.compose.material.icons.outlined.Star import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.SystemUpdate import androidx.compose.material.icons.outlined.VpnKey -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -43,9 +34,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalUriHandler @@ -64,7 +52,6 @@ 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.SettingsGroupGap import org.monogram.core.ui.components.SettingsTile import org.monogram.core.ui.rememberEnsuredFile import org.monogram.core.ui.rememberStrippedPreview @@ -160,60 +147,33 @@ internal fun LazyListScope.homeItems( identifier }, iconColor = MaterialTheme.colorScheme.primary, - position = - when { - accountUserIds.size == 1 -> ItemPosition.STANDALONE - index == 0 -> ItemPosition.TOP - index == accountUserIds.lastIndex -> ItemPosition.BOTTOM - else -> ItemPosition.MIDDLE - }, - onClick = + position = if (index == 0) ItemPosition.TOP else ItemPosition.MIDDLE, + selected = active, + titleColor = if (active) { - null + MaterialTheme.colorScheme.onSecondaryContainer } else { - { component.switchAccount(userId) } + MaterialTheme.colorScheme.onSurface }, - trailingContent = { - if (userId == activeAccountUserId) { - Icon(Icons.Outlined.Check, contentDescription = null) + onClick = { if (!active) component.switchAccount(userId) }, + onLongClick = + if (accountUserIds.size > 1) { + { onRemoveAccount(userId) } } else { - var expanded by remember { mutableStateOf(false) } - Box(Modifier.offset(x = 12.dp)) { - IconButton(onClick = { expanded = true }) { - Icon( - Icons.Outlined.MoreVert, - contentDescription = stringResource(org.monogram.core.ui.R.string.media_more_actions), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.settings_remove_account)) }, - onClick = { - expanded = false - onRemoveAccount(userId) - }, - ) - } - } - } - }, + null + }, + onLongClickLabel = stringResource(R.string.settings_remove_account), + trailingContent = {}, ) } } - if (accountUserIds.isNotEmpty()) { - item(key = "settings-account-gap") { Spacer(Modifier.height(SettingsGroupGap)) } - } item { SettingsTile( icon = Icons.Outlined.Add, title = stringResource(R.string.settings_add_account), iconColor = MaterialTheme.colorScheme.primary, - position = ItemPosition.STANDALONE, + position = if (accountUserIds.isEmpty()) ItemPosition.STANDALONE else ItemPosition.BOTTOM, onClick = onAddAccount, - trailingContent = { Chevron() }, ) } item { SectionHeader(stringResource(R.string.settings_section_settings)) } From 3deecc8f959bd19ca6bb8e95379a5f99359530fb Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 14 Nov 2026 20:13:37 +0300 Subject: [PATCH 7/9] fix(core): refactor avatar caching --- app/src/main/java/org/monogram/MonogramApp.kt | 7 +- .../monogram/core/database/ModelMappers.kt | 507 +++++----- .../monogram/core/database/OfflineWarmup.kt | 63 +- .../monogram/feature/chats/ChatsExecutor.kt | 947 ++++++++++-------- .../org/monogram/feature/chats/ChatsMerge.kt | 113 ++- .../feature/chats/ui/ChatListAppBar.kt | 2 +- .../org/monogram/feature/chats/ui/ChatRows.kt | 635 ++++++------ .../monogram/feature/chats/ui/ChatsContent.kt | 559 ++++++----- .../feature/dialog/ui/DialogTopBar.kt | 190 ++-- .../feature/dialog/ui/ForwardPicker.kt | 147 +-- .../feature/dialog/ui/MentionOverlay.kt | 203 ++-- .../feature/dialog/ui/MessageBubble.kt | 19 +- .../feature/settings/ui/SettingsContent.kt | 24 +- .../feature/settings/ui/SettingsData.kt | 388 +++---- .../feature/settings/ui/SettingsHome.kt | 3 +- .../monogram/network/http/MediaRepository.kt | 20 +- vendor/mono-rs | 2 +- 17 files changed, 2177 insertions(+), 1652 deletions(-) diff --git a/app/src/main/java/org/monogram/MonogramApp.kt b/app/src/main/java/org/monogram/MonogramApp.kt index 2d07d50e6..7b63f271e 100644 --- a/app/src/main/java/org/monogram/MonogramApp.kt +++ b/app/src/main/java/org/monogram/MonogramApp.kt @@ -422,7 +422,8 @@ class MonogramApp : Application() { suspend fun adoptAuthorizedAccount(session: AuthSession) = accountOperations.withLock { check( - accountRegistry.all() + accountRegistry + .all() .isEmpty() || accountRegistry.activeUserId == session.userId.value, ) { "Authorization requires an account-specific runtime" @@ -519,7 +520,9 @@ class MonogramApp : Application() { accountWorkManager.stop(userId) accountMediaRepositories.remove(userId)?.shutdown() ImageCache.release(account.mediaPath) - accountRuntimeManager.all().firstOrNull { it.account.userId == userId } + accountRuntimeManager + .all() + .firstOrNull { it.account.userId == userId } ?.let { runtime -> accountLiveLocations.remove(runtime.client)?.stopAuthorized() } 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..f1c13f571 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,214 +8,220 @@ 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), - title = title, - isChannel = isChannel, - isGroup = isGroup, - isForum = isForum, - left = left, - unreadCount = unreadCount, - lastMessagePreview = lastMessagePreview, - lastMessageOutgoing = lastMessageOutgoing, - lastMessageSenderName = lastMessageSenderName, - lastMessageMediaKind = lastMessageMediaKind, - lastMessageDate = lastMessageDate, - photoCacheKey = photoCacheKey, - archived = archived, - muted = muted, - muteOverride = muteOverride, - unreadMark = unreadMark, - unreadMentionsCount = unreadMentionsCount, - unreadReactionsCount = unreadReactionsCount, - isContact = isContact, - isBot = isBot, - isVerified = isVerified || isForcedVerifiedChat(id), - pinned = pinned, - pinnedOrder = pinnedOrder, - readInboxMaxId = readInboxMaxId, - readOutboxMaxId = readOutboxMaxId, - peerStatus = peerStatus, - peerStatusAt = peerStatusAt, - lastMediaThumbCacheKey = lastMediaThumbCacheKey, - lastMessageId = lastMessageId, - dialogScrollMessageId = dialogScrollMessageId, - canView = canView, - canSendPlain = canSendPlain, - canSendPhotos = canSendPhotos, - canForward = canForward, - canDeleteOthers = canDeleteOthers, - canManageTopics = canManageTopics, - emojiStatusDocumentId = emojiStatusDocumentId, - canSendPolls = canSendPolls, -) +fun ChatEntity.toModel(): Chat = + Chat( + id = PeerId(id), + title = title, + isChannel = isChannel, + isGroup = isGroup, + isForum = isForum, + left = left, + unreadCount = unreadCount, + lastMessagePreview = lastMessagePreview, + lastMessageOutgoing = lastMessageOutgoing, + lastMessageSenderName = lastMessageSenderName, + lastMessageMediaKind = lastMessageMediaKind, + lastMessageDate = lastMessageDate, + photoCacheKey = photoCacheKey, + archived = archived, + muted = muted, + muteOverride = muteOverride, + unreadMark = unreadMark, + unreadMentionsCount = unreadMentionsCount, + unreadReactionsCount = unreadReactionsCount, + isContact = isContact, + isBot = isBot, + isVerified = isVerified || isForcedVerifiedChat(id), + pinned = pinned, + pinnedOrder = pinnedOrder, + readInboxMaxId = readInboxMaxId, + readOutboxMaxId = readOutboxMaxId, + peerStatus = peerStatus, + peerStatusAt = peerStatusAt, + lastMediaThumbCacheKey = lastMediaThumbCacheKey, + lastMessageId = lastMessageId, + dialogScrollMessageId = dialogScrollMessageId, + canView = canView, + canSendPlain = canSendPlain, + canSendPhotos = canSendPhotos, + canForward = canForward, + canDeleteOthers = canDeleteOthers, + canManageTopics = canManageTopics, + emojiStatusDocumentId = emojiStatusDocumentId, + canSendPolls = canSendPolls, + ) -fun Chat.toEntity(): ChatEntity = ChatEntity( - id = id.value, - title = title, - isChannel = isChannel, - isGroup = isGroup, - isForum = isForum, - left = left, - unreadCount = unreadCount, - lastMessagePreview = lastMessagePreview, - lastMessageOutgoing = lastMessageOutgoing, - lastMessageSenderName = lastMessageSenderName, - lastMessageMediaKind = lastMessageMediaKind, - lastMessageDate = lastMessageDate, - photoCacheKey = photoCacheKey, - archived = archived, - muted = muted, - muteOverride = muteOverride, - unreadMark = unreadMark, - unreadMentionsCount = unreadMentionsCount, - unreadReactionsCount = unreadReactionsCount, - isContact = isContact, - isBot = isBot, - isVerified = isVerified, - pinned = pinned, - pinnedOrder = pinnedOrder, - readInboxMaxId = readInboxMaxId, - readOutboxMaxId = readOutboxMaxId, - peerStatus = peerStatus, - peerStatusAt = peerStatusAt, - lastMediaThumbCacheKey = lastMediaThumbCacheKey, - lastMessageId = lastMessageId, - dialogScrollMessageId = dialogScrollMessageId, - canView = canView, - canSendPlain = canSendPlain, - canSendPhotos = canSendPhotos, - canForward = canForward, - canDeleteOthers = canDeleteOthers, - canManageTopics = canManageTopics, - emojiStatusDocumentId = emojiStatusDocumentId, - canSendPolls = canSendPolls, -) +fun Chat.toEntity(): ChatEntity = + ChatEntity( + id = id.value, + title = title, + isChannel = isChannel, + isGroup = isGroup, + isForum = isForum, + left = left, + unreadCount = unreadCount, + lastMessagePreview = lastMessagePreview, + lastMessageOutgoing = lastMessageOutgoing, + lastMessageSenderName = lastMessageSenderName, + lastMessageMediaKind = lastMessageMediaKind, + lastMessageDate = lastMessageDate, + photoCacheKey = photoCacheKey, + archived = archived, + muted = muted, + muteOverride = muteOverride, + unreadMark = unreadMark, + unreadMentionsCount = unreadMentionsCount, + unreadReactionsCount = unreadReactionsCount, + isContact = isContact, + isBot = isBot, + isVerified = isVerified, + pinned = pinned, + pinnedOrder = pinnedOrder, + readInboxMaxId = readInboxMaxId, + readOutboxMaxId = readOutboxMaxId, + peerStatus = peerStatus, + peerStatusAt = peerStatusAt, + lastMediaThumbCacheKey = lastMediaThumbCacheKey, + lastMessageId = lastMessageId, + dialogScrollMessageId = dialogScrollMessageId, + canView = canView, + canSendPlain = canSendPlain, + canSendPhotos = canSendPhotos, + canForward = canForward, + canDeleteOthers = canDeleteOthers, + canManageTopics = canManageTopics, + emojiStatusDocumentId = emojiStatusDocumentId, + canSendPolls = canSendPolls, + ) -fun FolderEntity.toModel(): Folder = Folder( - id = id, - title = title, - emoticon = emoticon, - chatIds = parsePeerIds(chatIdsCsv), - pinnedChatIds = parsePeerIds(pinnedChatIdsCsv), - includeContacts = includeContacts, - includeNonContacts = includeNonContacts, - includeGroups = includeGroups, - includeChannels = includeChannels, - includeBots = includeBots, - excludeChatIds = parsePeerIds(excludeChatIdsCsv), - excludeMuted = excludeMuted, - excludeRead = excludeRead, - excludeArchived = excludeArchived, -) +fun FolderEntity.toModel(): Folder = + Folder( + id = id, + title = title, + emoticon = emoticon, + chatIds = parsePeerIds(chatIdsCsv), + pinnedChatIds = parsePeerIds(pinnedChatIdsCsv), + includeContacts = includeContacts, + includeNonContacts = includeNonContacts, + includeGroups = includeGroups, + includeChannels = includeChannels, + includeBots = includeBots, + excludeChatIds = parsePeerIds(excludeChatIdsCsv), + excludeMuted = excludeMuted, + excludeRead = excludeRead, + excludeArchived = excludeArchived, + ) -fun Folder.toEntity(position: Int = 0): FolderEntity = FolderEntity( - id = id, - title = title, - emoticon = emoticon, - chatIdsCsv = peerIdsCsv(chatIds), - pinnedChatIdsCsv = peerIdsCsv(pinnedChatIds), - includeContacts = includeContacts, - includeNonContacts = includeNonContacts, - includeGroups = includeGroups, - includeChannels = includeChannels, - includeBots = includeBots, - excludeChatIdsCsv = peerIdsCsv(excludeChatIds), - excludeMuted = excludeMuted, - excludeRead = excludeRead, - excludeArchived = excludeArchived, - position = position, -) +fun Folder.toEntity(position: Int = 0): FolderEntity = + FolderEntity( + id = id, + title = title, + emoticon = emoticon, + chatIdsCsv = peerIdsCsv(chatIds), + pinnedChatIdsCsv = peerIdsCsv(pinnedChatIds), + includeContacts = includeContacts, + includeNonContacts = includeNonContacts, + includeGroups = includeGroups, + includeChannels = includeChannels, + includeBots = includeBots, + excludeChatIdsCsv = peerIdsCsv(excludeChatIds), + excludeMuted = excludeMuted, + excludeRead = excludeRead, + excludeArchived = excludeArchived, + position = position, + ) -fun MessageEntity.toModel(): Message = Message( - id = MessageId(chatId = PeerId(chatId), id = id), - senderId = senderId?.let(::PeerId), - text = text, - date = date, - editDate = editDate, - outgoing = outgoing, - mediaCacheKey = mediaCacheKey, - mediaKind = mediaKind, - thumbCacheKey = thumbCacheKey, - mediaDuration = mediaDuration, - mediaWidth = mediaWidth, - mediaHeight = mediaHeight, - groupedId = groupedId, - fileName = fileName, - fileSize = fileSize, - supportsStreaming = supportsStreaming, - reactionsJson = reactionsJson, - repliesCount = repliesCount, - discussionPeerId = discussionPeerId, - replyQuote = replyQuote, - replyQuoteEntities = TextEntities.parse(replyQuoteEntitiesJson), - entities = TextEntities.parse(entitiesJson), - replyToMsgId = replyToMsgId, - replyToTopId = replyToTopId, - forumTopic = forumTopic, - fwdFrom = fwdFrom, - fwdFromId = fwdFromId, - fwdDate = fwdDate, - viaBot = viaBot, - senderName = senderName, - replyMarkup = ReplyMarkups.parse(replyMarkupJson), - pending = pending, - failed = failed, - randomId = randomId, - checklist = Checklists.parse(checklistJson), -) +fun MessageEntity.toModel(): Message = + Message( + id = MessageId(chatId = PeerId(chatId), id = id), + senderId = senderId?.let(::PeerId), + text = text, + date = date, + editDate = editDate, + outgoing = outgoing, + mediaCacheKey = mediaCacheKey, + mediaKind = mediaKind, + thumbCacheKey = thumbCacheKey, + mediaDuration = mediaDuration, + mediaWidth = mediaWidth, + mediaHeight = mediaHeight, + groupedId = groupedId, + fileName = fileName, + fileSize = fileSize, + supportsStreaming = supportsStreaming, + reactionsJson = reactionsJson, + repliesCount = repliesCount, + discussionPeerId = discussionPeerId, + replyQuote = replyQuote, + replyQuoteEntities = TextEntities.parse(replyQuoteEntitiesJson), + entities = TextEntities.parse(entitiesJson), + replyToMsgId = replyToMsgId, + replyToTopId = replyToTopId, + forumTopic = forumTopic, + fwdFrom = fwdFrom, + fwdFromId = fwdFromId, + fwdDate = fwdDate, + viaBot = viaBot, + senderName = senderName, + replyMarkup = ReplyMarkups.parse(replyMarkupJson), + pending = pending, + failed = failed, + randomId = randomId, + checklist = Checklists.parse(checklistJson), + ) -fun Message.toEntity(): MessageEntity = MessageEntity( - chatId = id.chatId.value, - id = id.id, - senderId = senderId?.value, - text = text, - date = date, - editDate = editDate, - outgoing = outgoing, - mediaCacheKey = mediaCacheKey, - mediaKind = mediaKind, - thumbCacheKey = thumbCacheKey, - mediaDuration = mediaDuration, - mediaWidth = mediaWidth, - mediaHeight = mediaHeight, - groupedId = groupedId, - fileName = fileName, - fileSize = fileSize, - supportsStreaming = supportsStreaming, - reactionsJson = reactionsJson, - repliesCount = repliesCount, - discussionPeerId = discussionPeerId, - replyQuote = replyQuote, - replyQuoteEntitiesJson = TextEntities.serialize(replyQuoteEntities), - entitiesJson = TextEntities.serialize(entities), - replyToMsgId = replyToMsgId, - replyToTopId = replyToTopId, - forumTopic = forumTopic, - fwdFrom = fwdFrom, - fwdFromId = fwdFromId, - fwdDate = fwdDate, - viaBot = viaBot, - senderName = senderName, - replyMarkupJson = ReplyMarkups.serialize(replyMarkup), - pending = pending, - failed = failed, - randomId = randomId, - checklistJson = Checklists.serialize(checklist), -) +fun Message.toEntity(): MessageEntity = + MessageEntity( + chatId = id.chatId.value, + id = id.id, + senderId = senderId?.value, + text = text, + date = date, + editDate = editDate, + outgoing = outgoing, + mediaCacheKey = mediaCacheKey, + mediaKind = mediaKind, + thumbCacheKey = thumbCacheKey, + mediaDuration = mediaDuration, + mediaWidth = mediaWidth, + mediaHeight = mediaHeight, + groupedId = groupedId, + fileName = fileName, + fileSize = fileSize, + supportsStreaming = supportsStreaming, + reactionsJson = reactionsJson, + repliesCount = repliesCount, + discussionPeerId = discussionPeerId, + replyQuote = replyQuote, + replyQuoteEntitiesJson = TextEntities.serialize(replyQuoteEntities), + entitiesJson = TextEntities.serialize(entities), + replyToMsgId = replyToMsgId, + replyToTopId = replyToTopId, + forumTopic = forumTopic, + fwdFrom = fwdFrom, + fwdFromId = fwdFromId, + fwdDate = fwdDate, + viaBot = viaBot, + senderName = senderName, + replyMarkupJson = ReplyMarkups.serialize(replyMarkup), + pending = pending, + failed = failed, + randomId = randomId, + checklistJson = Checklists.serialize(checklist), + ) fun PeerEntity.toProfile(): Profile { val extra = ProfileExtras.parse(extraJson) @@ -237,17 +243,21 @@ fun PeerEntity.toProfile(): Profile { } fun Profile.toPeerEntity(prior: PeerEntity?): PeerEntity { - val extra = ProfileExtras.mergeFull( - ProfileExtras.parse(prior?.extraJson), - 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() -> this.kind - else -> prior?.kind ?: "user" - } + val extra = + ProfileExtras.mergeFull( + ProfileExtras.parse(prior?.extraJson), + 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() -> this.kind + + else -> prior?.kind ?: "user" + } return PeerEntity( id = id.value, kind = kind, @@ -263,7 +273,11 @@ fun Profile.toPeerEntity(prior: PeerEntity?): PeerEntity { ) } -fun ProfileMember.toEntity(peerId: Long, position: Int, totalCount: Int = 0): ProfileMemberEntity = +fun ProfileMember.toEntity( + peerId: Long, + position: Int, + totalCount: Int = 0, +): ProfileMemberEntity = ProfileMemberEntity( peerId = peerId, userId = id.value, @@ -278,18 +292,23 @@ fun ProfileMember.toEntity(peerId: Long, position: Int, totalCount: Int = 0): Pr totalCount = totalCount, ) -fun ProfileMemberEntity.toModel(): ProfileMember = ProfileMember( - id = PeerId(userId), - title = title, - username = username, - avatarCacheKey = avatarCacheKey, - status = status, - statusAt = statusAt, - role = role, - rank = rank, -) +fun ProfileMemberEntity.toModel(): ProfileMember = + ProfileMember( + id = PeerId(userId), + title = title, + username = username, + avatarCacheKey = avatarCacheKey, + status = status, + statusAt = statusAt, + role = role, + rank = rank, + ) -fun Message.toProfileMediaEntity(peerId: Long, tab: String, position: Int): ProfileMediaEntity = +fun Message.toProfileMediaEntity( + peerId: Long, + tab: String, + position: Int, +): ProfileMediaEntity = ProfileMediaEntity( peerId = peerId, tab = tab, @@ -306,20 +325,24 @@ fun Message.toProfileMediaEntity(peerId: Long, tab: String, position: Int): Prof fileSize = fileSize, ) -fun ProfileMediaEntity.toMessage(): Message = Message( - id = MessageId(chatId = PeerId(chatId), id = messageId), - senderId = null, - 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), + senderId = null, + text = text, + date = date, + outgoing = outgoing, + mediaCacheKey = mediaCacheKey, + mediaKind = mediaKind, + thumbCacheKey = thumbCacheKey, + fileName = fileName, + fileSize = fileSize, + ) -fun Chat.toProfileCommonEntity(peerId: Long, position: Int): ProfileCommonEntity = +fun Chat.toProfileCommonEntity( + peerId: Long, + position: Int, +): ProfileCommonEntity = ProfileCommonEntity( peerId = peerId, chatId = id.value, @@ -330,16 +353,22 @@ fun Chat.toProfileCommonEntity(peerId: Long, position: Int): ProfileCommonEntity photoCacheKey = photoCacheKey, ) -fun ProfileCommonEntity.toChat(): Chat = Chat( - id = PeerId(chatId), - title = title, - isChannel = isChannel, - isGroup = isGroup, - photoCacheKey = photoCacheKey, -) +fun ProfileCommonEntity.toChat(): Chat = + Chat( + id = PeerId(chatId), + title = title, + isChannel = isChannel, + isGroup = isGroup, + photoCacheKey = photoCacheKey, + ) private fun parsePeerIds(csv: String): List = - csv.split(',').mapNotNull { it.trim().takeIf(String::isNotEmpty)?.toLongOrNull()?.let(::PeerId) } + 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/OfflineWarmup.kt b/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt index 0b8924477..a54e51db9 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 @@ -226,8 +226,12 @@ open class OfflineWarmup( .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() + ( + newest + + database + .messageDao() + .stickyIds(chatId, cutoffSeconds, 200) + ).distinct() kept.minOrNull()?.let { oldestKept -> database.messageTombstoneDao().deleteBelow(chatId, oldestKept) } @@ -336,8 +340,8 @@ open class OfflineWarmup( cloudDraftStates(rows).firstOrNull() ?: CloudDraftState( CloudDraft( chatId, - threadId - ) + threadId, + ), ) } }?.distinctUntilChanged() ?: emptyFlow() @@ -361,7 +365,9 @@ open class OfflineWarmup( private fun cloudDraftStates(rows: List): List { val stored = rows.associateBy { it.key } - return rows.mapNotNull { draftContextFromMetaKey(it.key) }.distinct() + return rows + .mapNotNull { draftContextFromMetaKey(it.key) } + .distinct() .map { (chatId, threadId) -> val modern = stored[cloudDraftMetaKey(chatId.value, threadId)] if (modern != null) { @@ -384,7 +390,7 @@ open class OfflineWarmup( ) } } - } + } } open suspend fun saveLocalCloudDraft( @@ -850,17 +856,26 @@ open class OfflineWarmup( database.withTransaction { val rows = when { - beforeId != null -> database.messageDao() - .olderThan(chatId.value, beforeId, limit) + beforeId != null -> { + database + .messageDao() + .olderThan(chatId.value, beforeId, limit) + } - afterId != null -> database.messageDao() - .newerThan(chatId.value, afterId, limit) + afterId != null -> { + database + .messageDao() + .newerThan(chatId.value, afterId, limit) + } - else -> database.messageDao().latestForChat(chatId.value, limit) + else -> { + database.messageDao().latestForChat(chatId.value, limit) + } } Triple( rows, - database.messageHoleDao() + database + .messageHoleDao() .forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY), database.metaDao().get("history_coverage:${chatId.value}") != null, ) @@ -1023,8 +1038,9 @@ open class OfflineWarmup( photoCacheKey = profile.avatarCacheKey ?: stored.photoCacheKey, peerStatus = profile.status ?: stored.peerStatus, peerStatusAt = profile.statusAt ?: stored.peerStatusAt, - emojiStatusDocumentId = profile.emojiStatusDocumentId - ?: stored.emojiStatusDocumentId, + emojiStatusDocumentId = + profile.emojiStatusDocumentId + ?: stored.emojiStatusDocumentId, ) upsertChats(listOf(updated)) } @@ -1125,9 +1141,14 @@ open class OfflineWarmup( val stored = mutableMapOf() for ((chatId, rows) in messages.groupBy { it.id.chatId.value }) { val ids = rows.map { it.id.id } - deleted += database.messageTombstoneDao().deletedIds(chatId, ids) - .map { MessageId(PeerId(chatId), it) } - database.messageDao().byIds(chatId, ids) + 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) { @@ -1323,7 +1344,7 @@ open class OfflineWarmup( UpdateBatchReceiptEntity( accountGeneration, batchId, - System.currentTimeMillis() + System.currentTimeMillis(), ), ) dao.pruneReceipts(accountGeneration, batchId - RECEIPT_WINDOW) @@ -1603,7 +1624,7 @@ open class OfflineWarmup( } send.items.forEach { require( - database.outgoingTextSendDao().get(it.randomId) == null + database.outgoingTextSendDao().get(it.randomId) == null, ) } database.outgoingMediaSendDao().enqueue(send) @@ -1904,8 +1925,8 @@ open class OfflineWarmup( encodeLiveLocation( current.copy( phase = phase, - revision = revision + 1 - ) + revision = revision + 1, + ), ), ), ) 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 2949b6b66..99a00f6cb 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 @@ -77,6 +77,7 @@ internal class ChatsExecutor( refreshSyncing = value publishSyncing() } + private var mainNetworkHasMore = true private val failedOffsetPeers = mutableSetOf() @@ -117,8 +118,10 @@ internal class ChatsExecutor( private var searchNextPeerId = 0L private var searchNextOffsetId = 0 private var lastRunSearchQuery = "" + override fun executeAction(action: Unit) { - warmup?.observeReadStates() + warmup + ?.observeReadStates() ?.map { rows -> rows.associateBy { it.id } } ?.distinctUntilChanged() ?.flowOn(Dispatchers.Default) @@ -132,8 +135,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. @@ -143,7 +145,8 @@ internal class ChatsExecutor( } scope.launch { loadNotifyDefaults() } refresh(force = false) - client.updates() + client + .updates() .onEach { update -> when (update) { is MtprotoUpdate.SyncState -> { @@ -153,11 +156,12 @@ internal class ChatsExecutor( is MtprotoUpdate.ChatsChanged -> { if (update.chats.isNotEmpty()) { - val mapped = withEffectiveMutes( - update.chats, - notifyDefaults, - notifyDefaultsLoaded, - ) + val mapped = + withEffectiveMutes( + update.chats, + notifyDefaults, + notifyDefaultsLoaded, + ) // Capture, merge, and publish under one lock. If refresh or an // incoming-message rebuild owns it, this update waits before taking // its snapshot, so it cannot queue a stale replacement behind them. @@ -166,11 +170,12 @@ internal class ChatsExecutor( val before = listedById() val listed = allListed() refreshMergeHook?.invoke() - val rebuilt = if (listed.size > 64) { - withContext(ioContext) { mergeChats(listed, mapped) } - } else { - mergeChats(listed, mapped) - } + val rebuilt = + if (listed.size > 64) { + withContext(ioContext) { mergeChats(listed, mapped) } + } else { + mergeChats(listed, mapped) + } republishListedLocked( rebuilt, fromCache = false, @@ -178,17 +183,21 @@ internal class ChatsExecutor( keep = paintKeep(), ) 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 } + changed = + mapped + .filter { 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) } } @@ -196,22 +205,28 @@ 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 -> { val current = state().chats.firstOrNull { it.id == update.userId } - val visible = current == null || LastSeen.affectsUi( - current.peerStatus, - current.peerStatusAt, - update.status, - update.statusAt, - ) + val visible = + current == null || + LastSeen.affectsUi( + current.peerStatus, + current.peerStatusAt, + update.status, + update.statusAt, + ) if (current != null && visible) { PeerPresence.record( update.userId.value, @@ -219,12 +234,14 @@ internal class ChatsExecutor( update.statusAt, ) } - val dotChanged = current != null && onlineDotChanged( - current.peerStatus, - current.peerStatusAt, - update.status, - update.statusAt, - ) + val dotChanged = + current != null && + onlineDotChanged( + current.peerStatus, + current.peerStatusAt, + update.status, + update.statusAt, + ) if (dotChanged) { listPublicationMutex.withLock { dispatch(Msg.Status(update.userId, update.status, update.statusAt)) @@ -271,18 +288,21 @@ internal class ChatsExecutor( is MtprotoUpdate.MessagesDeleted -> { scope.launch { - if (!update.persisted) 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) && + val affected = + state().chats.filter { chat -> + (update.chatId == null || update.chatId == chat.id) && chat.lastMessageId in ids - } + } for (chat in affected) { val latest = warmup?.messages(chat.id, 1)?.firstOrNull() dispatch(Msg.LatestReplaced(chat.id, latest)) @@ -325,8 +345,9 @@ internal class ChatsExecutor( is MtprotoUpdate.UnreadMentions -> { val current = state().chats.firstOrNull { it.id == update.chatId } - if (current == null || current.unreadMentionsCount != update.stillUnread.coerceAtLeast( - 0 + if (current == null || current.unreadMentionsCount != + update.stillUnread.coerceAtLeast( + 0, ) ) { listPublicationMutex.withLock { @@ -337,8 +358,9 @@ internal class ChatsExecutor( is MtprotoUpdate.UnreadReactions -> { val current = state().chats.firstOrNull { it.id == update.chatId } - if (current == null || current.unreadReactionsCount != update.stillUnread.coerceAtLeast( - 0 + if (current == null || current.unreadReactionsCount != + update.stillUnread.coerceAtLeast( + 0, ) ) { listPublicationMutex.withLock { @@ -378,30 +400,36 @@ 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() + }.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() } @@ -416,7 +444,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) && + val needsInitial = + changed && isFolderScopedStream(intent.folderId) && folderPaging[wire]?.started != true if (needsInitial) { dispatch(Msg.HasMore(true)) @@ -440,22 +469,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()) } } @@ -465,43 +506,47 @@ internal class ChatsExecutor( * Peer type defaults are what a dialog without its own mute setting inherits * (`account.getNotifySettings` for users / chats / broadcasts). */ - private suspend fun loadNotifyDefaults(force: Boolean = false) = notifyDefaultsMutex.withLock { - if (notifyDefaultsRefreshed && !force) return@withLock - val loggedOut = sessionStore?.isAuthorized() == false && - client.isLocallyAuthorized() != Outcome.Ok(true) - if (loggedOut) return@withLock - 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} " + + private suspend fun loadNotifyDefaults(force: Boolean = false) = + notifyDefaultsMutex.withLock { + if (notifyDefaultsRefreshed && !force) return@withLock + val loggedOut = + sessionStore?.isAuthorized() == false && + client.isLocallyAuthorized() != Outcome.Ok(true) + if (loggedOut) return@withLock + 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}", - ) - listPublicationMutex.withLock { - val current = state().chats - if (current.isNotEmpty()) { - val remapped = withEffectiveMutes(current, notifyDefaults, notifyDefaultsLoaded) - if (remapped != current) { - dispatch(Msg.Chats(remapped, fromCache = false, replace = true)) - // Keep the cache honest: a restart reads it before the defaults arrive again. - warmup?.upsertChats(remapped) + ) + listPublicationMutex.withLock { + val current = state().chats + if (current.isNotEmpty()) { + val remapped = withEffectiveMutes(current, notifyDefaults, notifyDefaultsLoaded) + if (remapped != current) { + dispatch(Msg.Chats(remapped, fromCache = false, replace = true)) + // Keep the cache honest: a restart reads it before the defaults arrive again. + warmup?.upsertChats(remapped) + } } } } - } private suspend fun notifySettingsFor(kind: String): NotifySettings? = when (val result = client.getNotifySettings(kind)) { @@ -513,59 +558,73 @@ 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) { - val updated = listPublicationMutex.withLock { - val current = state().chats.firstOrNull { it.id == chatId } ?: return - val next = if (specified) { - current.withOwnMute(muteUntil, nowEpochSeconds()) - } else if (current.muteOverride || !notifyDefaultsLoaded) { - current - } else { - withEffectiveMutes(listOf(current), notifyDefaults, true).single() + 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) { + current.withOwnMute(muteUntil, nowEpochSeconds()) + } else if (current.muteOverride || !notifyDefaultsLoaded) { + current + } else { + withEffectiveMutes(listOf(current), notifyDefaults, true).single() + } + if (next == current) return + dispatch(Msg.Chats(listOf(next), fromCache = false, replace = false)) + next } - if (next == current) return - dispatch(Msg.Chats(listOf(next), fromCache = false, replace = false)) - next - } warmup?.upsertChats(listOf(updated)) } - private fun wireFolderId(folderId: Int?): Int = when (folderId) { - null -> MAIN_FOLDER_WIRE_ID - ARCHIVE_FOLDER_ID -> ARCHIVE_FOLDER_WIRE_ID - else -> if (folderId in folderStreamRejected) MAIN_FOLDER_WIRE_ID else folderId - } + private fun wireFolderId(folderId: Int?): Int = + when (folderId) { + null -> MAIN_FOLDER_WIRE_ID + ARCHIVE_FOLDER_ID -> ARCHIVE_FOLDER_WIRE_ID + else -> if (folderId in folderStreamRejected) MAIN_FOLDER_WIRE_ID else folderId + } /** A folder has its own server-side dialog stream unless the server refused its id. */ - private fun isFolderScopedStream(folderId: Int?): Boolean = when (folderId) { - null, MAIN_FOLDER_WIRE_ID -> false - ARCHIVE_FOLDER_ID -> true - else -> folderId !in folderStreamRejected - } + private fun isFolderScopedStream(folderId: Int?): Boolean = + when (folderId) { + null, MAIN_FOLDER_WIRE_ID -> false + ARCHIVE_FOLDER_ID -> true + else -> folderId !in folderStreamRejected + } /** A custom folder needs several main-stream pages per gesture to surface new members. */ private fun pagesPerRequest(folderId: Int?): Int = if (folderId != null && folderId != ARCHIVE_FOLDER_ID) CUSTOM_FOLDER_PAGES else 1 - private fun fallbackArchiveFolderId(folderId: Int?): Int? = folderId?.takeIf { - it != ARCHIVE_FOLDER_ID && it in folderStreamRejected - } + private fun fallbackArchiveFolderId(folderId: Int?): Int? = + folderId?.takeIf { + it != ARCHIVE_FOLDER_ID && it in folderStreamRejected + } private fun hasMoreFor(folderId: Int?): Boolean { 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 -> + folderId == ARCHIVE_FOLDER_ID -> { cachedTail.any { it.isArchiveListRow() } || (folderPaging[wire]?.hasMore ?: true) + } - else -> folderPaging[wire]?.hasMore ?: true + else -> { + folderPaging[wire]?.hasMore ?: true + } } } @@ -576,11 +635,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()) + } } } } @@ -590,9 +653,14 @@ internal class ChatsExecutor( if (message.outgoing) return message if (!message.senderName.isNullOrBlank()) return message val senderId = message.senderId?.value ?: return message - val title = withContext(ioContext) { - sessionStore?.readProfile(senderId)?.title?.trim().orEmpty() - } + val title = + withContext(ioContext) { + sessionStore + ?.readProfile(senderId) + ?.title + ?.trim() + .orEmpty() + } return if (title.isEmpty()) message else message.copy(senderName = title) } @@ -602,7 +670,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)) @@ -612,7 +679,9 @@ internal class ChatsExecutor( } } - is Outcome.Err -> Unit + is Outcome.Err -> { + Unit + } } } } @@ -652,17 +721,18 @@ internal class ChatsExecutor( "cache hit count=${cached.size} main=$roomMainCount", ) val muted = muteChats(cached) - val (first, tail) = if (!hasMemory) { - if (muted.size > 64) { - withContext(ioContext) { paintDialogsWindow(muted) } + val (first, tail) = + if (!hasMemory) { + if (muted.size > 64) { + withContext(ioContext) { paintDialogsWindow(muted) } + } else { + paintDialogsWindow(muted) + } + } else if (muted.size > 64) { + withContext(ioContext) { sortChats(muted) } to emptyList() } else { - paintDialogsWindow(muted) + sortChats(muted) to emptyList() } - } else if (muted.size > 64) { - withContext(ioContext) { sortChats(muted) } to emptyList() - } else { - sortChats(muted) to emptyList() - } dispatch( Msg.Chats( first, @@ -676,14 +746,16 @@ internal class ChatsExecutor( recoverMissingTitles() if (cachedTail.none { it.isMainListRow() }) { val tail = listTail(first, archived = false) - val extra = if (warmup == null || tail == null) { - emptyList() - } else { - warmup.chatsAfter(ChatCacheCursor.from(tail), DIALOGS_NETWORK_PAGE) - }.filter { it.isMainListRow() } + val extra = + if (warmup == null || tail == null) { + emptyList() + } else { + warmup.chatsAfter(ChatCacheCursor.from(tail), DIALOGS_NETWORK_PAGE) + }.filter { it.isMainListRow() } cachedTail = extra + cachedTail } - val loadedMain = first.count { it.isMainListRow() } + + val loadedMain = + first.count { it.isMainListRow() } + cachedTail.count { it.isMainListRow() } dispatch( Msg.HasMore( @@ -706,11 +778,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. - var result = coroutineScope { - val dialogs = async { getChatsTimed("get") } - if (!notifyDefaultsLoaded) loadNotifyDefaults() - dialogs.await() - } + var result = + coroutineScope { + val dialogs = async { getChatsTimed("get") } + if (!notifyDefaultsLoaded) loadNotifyDefaults() + dialogs.await() + } if (result is Outcome.Err && state().chats.isNotEmpty() && !result.telegramError.requiresReauth @@ -722,7 +795,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) @@ -741,27 +814,30 @@ internal class ChatsExecutor( listPublicationMutex.withLock { val source = allListed() refreshMergeHook?.invoke() - val merged = if (source.size > 64) { - withContext(ioContext) { + val merged = + if (source.size > 64) { + withContext(ioContext) { + withNetworkPinOrder(mergeChats(source, mapped), mapped) + } + } else { withNetworkPinOrder(mergeChats(source, mapped), mapped) } - } else { - withNetworkPinOrder(mergeChats(source, mapped), mapped) - } persisted = merged val mergedById = merged.associateBy { it.id } - pruneRows = mapped.filter { net -> - val local = mergedById[net.id] - local == null || net.lastMessageId >= local.lastMessageId - } + pruneRows = + mapped.filter { net -> + val local = mergedById[net.id] + local == null || net.lastMessageId >= local.lastMessageId + } republishListedLocked( merged, fromCache = false, - hasMore = if (activeFolderId == ARCHIVE_FOLDER_ID) { - null - } else { - dialogsHasMore(result.value.size) - }, + hasMore = + if (activeFolderId == ARCHIVE_FOLDER_ID) { + null + } else { + dialogsHasMore(result.value.size) + }, keep = paintKeep(), ) } @@ -805,16 +881,21 @@ internal class ChatsExecutor( setRefreshSyncing(false) catalogSeed?.let { seed -> if (generation == catalogGeneration) { - 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") + 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) { @@ -863,14 +944,15 @@ internal class ChatsExecutor( scope.launch { try { val tail = listTail(state().chats + cachedTail, archived = true) - val roomPage = if (tail == null) { - emptyList() - } else { - warmup.chatsAfter( - ChatCacheCursor.from(tail), - DIALOGS_NETWORK_PAGE, - ) - }.filter { it.isArchiveListRow() } + val roomPage = + if (tail == null) { + emptyList() + } else { + warmup.chatsAfter( + ChatCacheCursor.from(tail), + DIALOGS_NETWORK_PAGE, + ) + }.filter { it.isArchiveListRow() } AppLog.api("chats", "loadMore archive room count=${roomPage.size}") if (roomPage.isNotEmpty()) { dispatch(Msg.Append(roomPage)) @@ -917,11 +999,12 @@ internal class ChatsExecutor( scope.launch { try { val tail = listTail(state().chats + cachedTail, archived = false) - val roomPage = if (tail == null) { - emptyList() - } else { - warmup.chatsAfter(ChatCacheCursor.from(tail), DIALOGS_NETWORK_PAGE) - }.filter { it.isMainListRow() } + val roomPage = + if (tail == null) { + emptyList() + } else { + warmup.chatsAfter(ChatCacheCursor.from(tail), DIALOGS_NETWORK_PAGE) + }.filter { it.isMainListRow() } AppLog.api("chats", "loadMore room count=${roomPage.size}") if (roomPage.isNotEmpty()) { dispatch(Msg.Append(roomPage)) @@ -979,36 +1062,53 @@ internal class ChatsExecutor( } val wire = wireFolderId(requestFolderId) val archiveFallback = fallbackArchiveFolderId(requestFolderId) - val useArchiveFallback = archiveFallback != null && + val useArchiveFallback = + archiveFallback != null && folderArchivePaging[archiveFallback]?.hasMore != false && (!mainNetworkHasMore || fetched % 2 == 0) val folderScoped = isFolderScopedStream(requestFolderId) val pageBudget = pagesPerRequest(requestFolderId) - val stateful = when { - useArchiveFallback -> folderArchivePaging.getOrPut(archiveFallback) { FolderPaging() } - folderScoped -> folderPaging.getOrPut(wire) { FolderPaging() } - else -> null - } - val cursor = when { - stateful != null && !stateful.started -> Triple(0, 0, 0L) - stateful != null -> dialogsPageCursor( - stateful.page, - failedOffsetPeers, - skipArchived = false, - ) + val stateful = + when { + useArchiveFallback -> folderArchivePaging.getOrPut(archiveFallback) { FolderPaging() } + folderScoped -> folderPaging.getOrPut(wire) { FolderPaging() } + else -> null + } + val cursor = + when { + stateful != null && !stateful.started -> { + Triple(0, 0, 0L) + } - archiveFallback != null && !mainNetworkHasMore -> null - else -> dialogsPageCursor(lastNetworkPage, failedOffsetPeers) - } + stateful != null -> { + dialogsPageCursor( + stateful.page, + failedOffsetPeers, + skipArchived = false, + ) + } + + archiveFallback != null && !mainNetworkHasMore -> { + null + } + + else -> { + dialogsPageCursor(lastNetworkPage, failedOffsetPeers) + } + } if (cursor == null) { if (stateful != null) { stateful.hasMore = false } 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. @@ -1038,11 +1138,12 @@ internal class ChatsExecutor( "loadMore folder=$requestFolder pages=$fetched/$pageBudget " + "offsetDate=$date offsetId=$offsetId peer=$peerId", ) - val result = if (useArchiveFallback || folderScoped) { - client.loadMoreFolderChats(requestFolder, date, offsetId, peerId) - } else { - client.loadMoreChats(date, offsetId, peerId) - } + val result = + if (useArchiveFallback || folderScoped) { + client.loadMoreFolderChats(requestFolder, date, offsetId, peerId) + } else { + client.loadMoreChats(date, offsetId, peerId) + } if (requestGeneration != folderSelectionGeneration || requestFolderId != activeFolderId) { paged = true return@run @@ -1078,11 +1179,12 @@ internal class ChatsExecutor( ) } AppLog.api("chats", "loadMore network count=${page.size}") - val mapped = withEffectiveMutes( - page, - notifyDefaults, - notifyDefaultsLoaded, - ) + val mapped = + withEffectiveMutes( + page, + notifyDefaults, + notifyDefaultsLoaded, + ) dispatch(Msg.Append(mapped)) if (mapped.isNotEmpty()) { warmup?.upsertChats(mapped) @@ -1115,7 +1217,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) { @@ -1142,7 +1247,8 @@ internal class ChatsExecutor( } } if (!paged) { - val stillCurrent = requestGeneration == folderSelectionGeneration && + val stillCurrent = + requestGeneration == folderSelectionGeneration && requestFolderId == activeFolderId val archiveFallback = fallbackArchiveFolderId(requestFolderId) if (!stillCurrent) { @@ -1199,29 +1305,32 @@ internal class ChatsExecutor( } val chat = state().chats.firstOrNull { it.id == chatId } val named = chat?.isGroup == true || chat?.isChannel == true - val name = if (named) { - sessionStore?.readProfile(userId.value)?.title - ?: state().chats.firstOrNull { it.id == userId }?.title - ?: chatMap[userId.value]?.name - ?: "" - } else { - "" - } + val name = + if (named) { + sessionStore?.readProfile(userId.value)?.title + ?: state().chats.firstOrNull { it.id == userId }?.title + ?: chatMap[userId.value]?.name + ?: "" + } else { + "" + } chatMap.remove(userId.value) - chatMap[userId.value] = TypingPresence( - name = name, - action = update.action.ifBlank { ChatActionKind.Typing.wire }, - ) + chatMap[userId.value] = + TypingPresence( + name = name, + action = update.action.ifBlank { ChatActionKind.Typing.wire }, + ) dispatch(typingMessage(chatId)) - typingJobs[jobKey] = scope.launch { - delay(6_000.milliseconds) - typingJobs.remove(jobKey) - typingByChat[chatId.value]?.remove(userId.value) - if (typingByChat[chatId.value].isNullOrEmpty()) { - typingByChat.remove(chatId.value) + typingJobs[jobKey] = + scope.launch { + delay(6_000.milliseconds) + typingJobs.remove(jobKey) + typingByChat[chatId.value]?.remove(userId.value) + if (typingByChat[chatId.value].isNullOrEmpty()) { + typingByChat.remove(chatId.value) + } + dispatch(typingMessage(chatId)) } - dispatch(typingMessage(chatId)) - } } private fun allListed(): List = state().chats + cachedTail @@ -1242,11 +1351,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) + } + + activeFolderId != null && activeFolderId != MAIN_FOLDER_WIRE_ID -> { known.coerceAtLeast(ARCHIVE_PAINT_LIMIT) + } - else -> ARCHIVE_PAINT_LIMIT + else -> { + ARCHIVE_PAINT_LIMIT + } } } @@ -1266,11 +1381,12 @@ internal class ChatsExecutor( keep: Int, ) { val archiveLimit = archiveKeep(merged) - val (first, tail) = if (merged.size > 64) { - withContext(ioContext) { paintDialogsWindow(merged, keep, archiveLimit) } - } else { - paintDialogsWindow(merged, keep, archiveLimit) - } + val (first, tail) = + if (merged.size > 64) { + withContext(ioContext) { paintDialogsWindow(merged, keep, archiveLimit) } + } else { + paintDialogsWindow(merged, keep, archiveLimit) + } cachedTail = tail val painted = state().chats val sameWindow = painted == first @@ -1290,8 +1406,9 @@ 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() } @@ -1305,45 +1422,53 @@ internal class ChatsExecutor( private fun recoverMissingTitles() { if (titleRecoveryJob?.isActive == true) return - titleRecoveryJob = scope.launch { - // Resolve sequentially, once per refresh, so missing peers cannot flood the RPC queue. - while (true) { - val missing = state().chats.firstOrNull { - it.id !in attemptedTitleRecovery && isPlaceholderPeerTitle( - it.title, - it.id.value - ) - } ?: break - attemptedTitleRecovery += missing.id - val cached = sessionStore?.readProfile(missing.id.value) - val profile = cached?.takeUnless { isPlaceholderPeerTitle(it.title, it.id.value) } - ?: when (val result = client.getProfile(missing.id)) { - is Outcome.Ok -> result.value - is Outcome.Err -> continue - } - if (isPlaceholderPeerTitle(profile.title, missing.id.value)) continue - sessionStore?.upsertProfile(profile) - // Re-read after the RPC: messages and read state may have changed while waiting. - val updated = listPublicationMutex.withLock { - val current = listedChat(missing.id) ?: return@withLock null - if (!isPlaceholderPeerTitle( - current.title, - current.id.value - ) - ) 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, - isVerified = profile.isVerified, - ) - dispatch(Msg.Chats(listOf(next), fromCache = false, replace = false)) - next - } ?: continue - warmup?.upsertChats(listOf(updated)) + titleRecoveryJob = + scope.launch { + // Resolve sequentially, once per refresh, so missing peers cannot flood the RPC queue. + while (true) { + val missing = + state().chats.firstOrNull { + it.id !in attemptedTitleRecovery && + isPlaceholderPeerTitle( + it.title, + it.id.value, + ) + } ?: break + attemptedTitleRecovery += missing.id + val cached = sessionStore?.readProfile(missing.id.value) + val profile = + cached?.takeUnless { isPlaceholderPeerTitle(it.title, it.id.value) } + ?: when (val result = client.getProfile(missing.id)) { + is Outcome.Ok -> result.value + is Outcome.Err -> continue + } + if (isPlaceholderPeerTitle(profile.title, missing.id.value)) continue + sessionStore?.upsertProfile(profile) + // Re-read after the RPC: messages and read state may have changed while waiting. + val updated = + listPublicationMutex.withLock { + val current = listedChat(missing.id) ?: return@withLock null + if (!isPlaceholderPeerTitle( + current.title, + current.id.value, + ) + ) { + 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, + isVerified = profile.isVerified, + ) + dispatch(Msg.Chats(listOf(next), fromCache = false, replace = false)) + next + } ?: continue + warmup?.upsertChats(listOf(updated)) + } } - } } private fun absorbIncoming( @@ -1353,47 +1478,62 @@ internal class ChatsExecutor( ) { scope.launch { var persistHidden: Chat? = null - val published = listPublicationMutex.withLock { - val current = listedChat(message.id.chatId) - when { - current == null -> false - !current.isShownInChatList() -> { - persistHidden = - current.withIncomingMessage(message, accountGeneration, acceptedOrder) - true - } + val published = + listPublicationMutex.withLock { + val current = listedChat(message.id.chatId) + when { + current == null -> { + false + } - else -> { - cachedTail = cachedTail.filter { it.id != current.id } - val listed = buildList { - addAll(state().chats) - addAll(cachedTail) - if (none { it.id == current.id }) add(current) + !current.isShownInChatList() -> { + persistHidden = + current.withIncomingMessage( + message, + accountGeneration, + acceptedOrder + ) + true + } + + else -> { + cachedTail = cachedTail.filter { it.id != current.id } + val listed = + buildList { + addAll(state().chats) + addAll(cachedTail) + if (none { it.id == current.id }) add(current) + } + republishListedLocked( + applyIncomingMessage( + listed, + message, + accountGeneration, + acceptedOrder + ), + fromCache = false, + hasMore = null, + keep = paintKeep(), + ) + true } - republishListedLocked( - applyIncomingMessage(listed, message, accountGeneration, acceptedOrder), - fromCache = false, - hasMore = null, - keep = paintKeep(), - ) - true } } - } persistHidden?.let { warmup?.upsertChats(listOf(it)) } if (published) return@launch - val stored = withContext(ioContext) { - warmup?.chat(message.id.chatId) - } ?: return@launch + val stored = + withContext(ioContext) { + warmup?.chat(message.id.chatId) + } ?: return@launch if (!stored.isMainListRow()) { warmup?.upsertChats( listOf( stored.withIncomingMessage( message, accountGeneration, - acceptedOrder - ) - ) + acceptedOrder, + ), + ), ) return@launch } @@ -1403,7 +1543,7 @@ internal class ChatsExecutor( allListed() + stored, message, accountGeneration, - acceptedOrder + acceptedOrder, ), fromCache = false, hasMore = null, @@ -1425,11 +1565,12 @@ internal class ChatsExecutor( // 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) - } + 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) { @@ -1479,11 +1620,14 @@ internal class ChatsExecutor( val seedCursor = dialogsPageCursor(seed, skipArchived = !archived) val saved = warmup?.oldestListed(archived)?.let { dialogsPageOffset(it) } if (!archived && seed.isNotEmpty() && !dialogsHasMore(seed.size)) { - if (saved != null && seedCursor != null && offsetIsOlder( + 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 @@ -1495,7 +1639,7 @@ 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) || @@ -1512,7 +1656,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() @@ -1530,11 +1677,12 @@ internal class ChatsExecutor( searchNextRate = 0 searchNextPeerId = 0L searchNextOffsetId = 0 - searchJob = scope.launch { - if (!immediate) delay(GLOBAL_SEARCH_DEBOUNCE_MS) - if (gen != searchGen) return@launch - runSearch(trimmed, gen, reset = true) - } + searchJob = + scope.launch { + if (!immediate) delay(GLOBAL_SEARCH_DEBOUNCE_MS) + if (gen != searchGen) return@launch + runSearch(trimmed, gen, reset = true) + } } private fun loadMoreSearch() { @@ -1550,12 +1698,17 @@ internal class ChatsExecutor( } val gen = searchGen searchMoreJob?.cancel() - searchMoreJob = scope.launch { - runSearch(query, gen, reset = false) - } + searchMoreJob = + scope.launch { + runSearch(query, gen, reset = false) + } } - 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 @@ -1570,19 +1723,21 @@ internal class ChatsExecutor( try { if (reset) { coroutineScope { - val contactsDeferred = async { - client.contactsSearch(query, GLOBAL_SEARCH_LIMIT) - } - val globalDeferred = async { - client.searchGlobal( - query = query, - offsetRate = 0, - offsetPeerId = PeerId(0), - offsetId = 0, - limit = GLOBAL_SEARCH_LIMIT, - folderId = folderId, - ) - } + val contactsDeferred = + async { + client.contactsSearch(query, GLOBAL_SEARCH_LIMIT) + } + val globalDeferred = + async { + client.searchGlobal( + query = query, + offsetRate = 0, + offsetPeerId = PeerId(0), + offsetId = 0, + limit = GLOBAL_SEARCH_LIMIT, + folderId = folderId, + ) + } val contacts = contactsDeferred.await() val global = globalDeferred.await() if (gen != searchGen) return@coroutineScope @@ -1595,23 +1750,25 @@ internal class ChatsExecutor( ) } } else { - val global = client.searchGlobal( - query = query, - offsetRate = searchNextRate, - offsetPeerId = PeerId(searchNextPeerId), - offsetId = searchNextOffsetId, - limit = GLOBAL_SEARCH_LIMIT, - folderId = folderId, - ) + val global = + client.searchGlobal( + query = query, + offsetRate = searchNextRate, + offsetPeerId = PeerId(searchNextPeerId), + offsetId = searchNextOffsetId, + limit = GLOBAL_SEARCH_LIMIT, + folderId = folderId, + ) if (gen != searchGen) return applySearchResults( gen = gen, - contacts = Outcome.Ok( - ContactsSearch( - people = state().searchPeople, - chats = state().searchChats, + contacts = + Outcome.Ok( + ContactsSearch( + people = state().searchPeople, + chats = state().searchChats, + ), ), - ), global = global, knownIds = knownIds, reset = false, @@ -1638,13 +1795,16 @@ internal class ChatsExecutor( when (contacts) { is Outcome.Ok -> { people = excludeKnownPeers(contacts.value.people, knownIds) - foundChats = excludeKnownPeers( - contacts.value.chats, - knownIds + people.map { it.id.value }, - ) + foundChats = + excludeKnownPeers( + contacts.value.chats, + knownIds + people.map { it.id.value }, + ) } - is Outcome.Err -> contactsErr = contacts.telegramError + is Outcome.Err -> { + contactsErr = contacts.telegramError + } } when (global) { is Outcome.Ok -> { @@ -1652,16 +1812,20 @@ internal class ChatsExecutor( searchNextRate = page.nextRate searchNextPeerId = page.nextPeerId.value searchNextOffsetId = page.nextOffsetId - val existing = if (reset) emptySet() else { - state().searchMessages.map { it.id }.toSet() - } + val existing = + if (reset) { + emptySet() + } else { + state().searchMessages.map { it.id }.toSet() + } messages = page.messages.filter { it.id !in existing } - val hasMore = globalSearchHasMore( - page.messages.size, - page.nextRate, - page.nextPeerId.value, - page.nextOffsetId, - ) + val hasMore = + globalSearchHasMore( + page.messages.size, + page.nextRate, + page.nextPeerId.value, + page.nextOffsetId, + ) dispatch( Msg.SearchPage( people = people, @@ -1708,10 +1872,11 @@ internal class ChatsExecutor( private fun typingMessage(chatId: PeerId): Msg.Typing { val chat = state().chats.firstOrNull { it.id == chatId } val named = chat?.isGroup == true || chat?.isChannel == true - val shown = displayedChatAction( - typingByChat[chatId.value]?.values.orEmpty(), - named = named, - ) + val shown = + displayedChatAction( + typingByChat[chatId.value]?.values.orEmpty(), + named = named, + ) return Msg.Typing( chatId = chatId, typing = shown != null, @@ -1731,4 +1896,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..34fc3d33b 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 @@ -25,14 +28,17 @@ internal fun applyReadStates(chats: List, rows: Map): unread == chat.unreadCount && mentions == chat.unreadMentionsCount && reactions == chat.unreadReactionsCount - ) return@forEachIndexed + ) { + return@forEachIndexed + } val target = updated ?: chats.toMutableList().also { updated = it } - target[index] = chat.copy( - readInboxMaxId = readInboxMaxId, - unreadCount = unread, - unreadMentionsCount = mentions, - unreadReactionsCount = reactions, - ) + target[index] = + chat.copy( + readInboxMaxId = readInboxMaxId, + unreadCount = unread, + unreadMentionsCount = mentions, + unreadReactionsCount = reactions, + ) } return updated ?: chats } @@ -53,11 +59,18 @@ private val chatListOrder = .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() } +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 } @@ -67,31 +80,37 @@ internal fun mergeChats(current: List, extra: List): List { return@forEach } val prev = byId[incoming.id.value] - val pinnedOrder = when { - incoming.pinned && incoming.pinnedOrder != Int.MAX_VALUE -> incoming.pinnedOrder - incoming.pinned -> prev?.pinnedOrder ?: incoming.pinnedOrder - else -> Int.MAX_VALUE - } + val pinnedOrder = + when { + incoming.pinned && incoming.pinnedOrder != Int.MAX_VALUE -> incoming.pinnedOrder + incoming.pinned -> prev?.pinnedOrder ?: incoming.pinnedOrder + else -> Int.MAX_VALUE + } val live = typing[incoming.id.value] val stale = prev != null && incoming.lastMessageId > 0 && prev.lastMessageId > incoming.lastMessageId val merged = incoming.mergeLocalCache(prev) - byId[incoming.id.value] = merged.copy( - title = preferredPeerTitle(incoming.title, prev?.title, incoming.id.value), - typing = live != null, - typingName = live?.typingName, - 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, - ) + byId[incoming.id.value] = + merged.copy( + title = preferredPeerTitle(incoming.title, prev?.title, incoming.id.value), + typing = live != null, + typingName = live?.typingName, + 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, + ) } return sortChats(byId.values) } /** 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,7 +125,10 @@ internal fun withNetworkPinOrder(merged: List, network: List): List< return changed ?: merged } -internal fun applyEditedMessage(chats: List, message: Message): List = +internal fun applyEditedMessage( + chats: List, + message: Message, +): List = chats.map { chat -> if (chat.id == message.id.chatId && chat.lastMessageId == message.id.id) { chat.copy( @@ -143,7 +165,11 @@ internal fun applyLatestReplacement( } } -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 +180,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 +195,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 +210,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) { @@ -191,11 +229,12 @@ internal fun Chat.withIncomingMessage( message: Message, accountGeneration: Long = 0, acceptedOrder: Long = 0, -): Chat = mergeIncomingMessage( - message, - accountGeneration = accountGeneration, - acceptedOrder = acceptedOrder, -) +): Chat = + mergeIncomingMessage( + message, + accountGeneration = accountGeneration, + acceptedOrder = acceptedOrder, + ) internal fun applyIncomingMessage( chats: List, 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 e1a43cf98..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 @@ -312,7 +312,7 @@ private fun AccountMenuAvatar(account: AccountMenuItem) { 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) } + val preview = rememberStrippedPreview(identity) { repo.peerInlineThumbJpeg(peerId, cacheKey) } val file = rememberEnsuredFile( generation = generation, 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 fe22553b7..1f9b8037a 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 @@ -165,7 +165,7 @@ fun ChatsContent( Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ChatListSkeleton( itemCount = DIALOGS_PAINT_LIMIT, - showAvatar = appearance.showChatAvatars + showAvatar = appearance.showChatAvatars, ) } return @@ -173,12 +173,13 @@ fun ChatsContent( var selectedFolderId by rememberSaveable { mutableStateOf(null) } // The list opens on the first folder; a tap or the archive outranks that default. var folderChoiceMade by rememberSaveable { mutableStateOf(false) } - val defaultFolder = remember(folders, appearance.showAllChats) { - defaultFolderId( - folders, - appearance.showAllChats - ) - } + val defaultFolder = + remember(folders, appearance.showAllChats) { + defaultFolderId( + folders, + appearance.showAllChats, + ) + } var archiveOpen by rememberSaveable { mutableStateOf(false) } var searchOpen by rememberSaveable { mutableStateOf(false) } var folderMenu by remember { mutableStateOf(null) } @@ -203,15 +204,17 @@ fun ChatsContent( val homeFolderId = selectedFolderId.takeUnless { it == ARCHIVE_FOLDER_ID } val viewingArchive = archiveOpen || selectedFolderId == ARCHIVE_FOLDER_ID val selectFolder: (Int?) -> Unit = { id -> - val next = onFolderChipClick( - selectedId = homeFolderId, - tappedId = id, - currentScroll = FolderListScroll( - index = listState.firstVisibleItemIndex, - offset = listState.firstVisibleItemScrollOffset, - ), - saved = folderScroll, - ) + val next = + onFolderChipClick( + selectedId = homeFolderId, + tappedId = id, + currentScroll = + FolderListScroll( + index = listState.firstVisibleItemIndex, + offset = listState.firstVisibleItemScrollOffset, + ), + saved = folderScroll, + ) folderScroll = next.saved selectedFolderId = next.selectedId folderChoiceMade = true @@ -223,12 +226,14 @@ fun ChatsContent( val stableSelectFolder: (Int?) -> Unit = remember { { id -> selectFolderState.value(id) } } val openFoldersState = rememberUpdatedState(component::onOpenFolders) val stableOpenFolders: () -> Unit = remember { { openFoldersState.value() } } - val chipLongPressState = rememberUpdatedState<(FolderChipItem) -> Unit>( - newValue = { item -> folderMenu = item }, - ) - val stableChipLongPress: (FolderChipItem) -> Unit = remember { - { item -> chipLongPressState.value(item) } - } + val chipLongPressState = + rememberUpdatedState<(FolderChipItem) -> Unit>( + newValue = { item -> folderMenu = item }, + ) + val stableChipLongPress: (FolderChipItem) -> Unit = + remember { + { item -> chipLongPressState.value(item) } + } val loadMoreState = rememberUpdatedState(component::onLoadMore) val stableLoadMore: () -> Unit = remember { { loadMoreState.value() } } val retrySearchState = rememberUpdatedState(component::onRetrySearch) @@ -243,25 +248,28 @@ fun ChatsContent( val markReadState = rememberUpdatedState(component::onMarkRead) val stableMarkReadIds: (List) -> Unit = remember { { ids -> markReadState.value(ids) } } val stableMarkReadOne: (PeerId) -> Unit = remember { { id -> markReadState.value(listOf(id)) } } - val markUnreadState = rememberUpdatedState<(PeerId) -> Unit>( - newValue = { id -> component.onMarkUnread(id, true) }, - ) + val markUnreadState = + rememberUpdatedState<(PeerId) -> Unit>( + newValue = { id -> component.onMarkUnread(id, true) }, + ) val stableMarkUnread: (PeerId) -> Unit = remember { { id -> markUnreadState.value(id) } } val queryState = rememberUpdatedState(component::onQueryChanged) val stableQueryChanged: (String) -> Unit = remember { { value -> queryState.value(value) } } val stableDismissFolderMenu = remember { { folderMenu = null } } val stableDismissRowMenu = remember { { rowMenu = null } } val stableRowMenu: (Chat) -> Unit = remember { { chat -> rowMenu = chat } } - val markFolderReadState = rememberUpdatedState<(List) -> Unit> { ids -> - folderMenu = null - markReadState.value(ids) - } + val markFolderReadState = + rememberUpdatedState<(List) -> Unit> { ids -> + folderMenu = null + markReadState.value(ids) + } val stableMarkFolderRead: (List) -> Unit = remember { { ids -> markFolderReadState.value(ids) } } - val editFoldersState = rememberUpdatedState { - folderMenu = null - openFoldersState.value() - } + val editFoldersState = + rememberUpdatedState { + folderMenu = null + openFoldersState.value() + } val stableEditFolders: () -> Unit = remember { { editFoldersState.value() } } // Paging follows the visible folder: a rule-based folder needs its own dialog stream. LaunchedEffect(homeFolderId, viewingArchive) { @@ -273,8 +281,11 @@ 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 }) || + 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 @@ -299,12 +310,13 @@ fun ChatsContent( val openArchiveState = rememberUpdatedState(openArchive) val stableOpenArchive: () -> Unit = remember { { openArchiveState.value() } } DisposableEffect(component, viewingArchive) { - val callback = BackCallback(isEnabled = viewingArchive) { - searchOpen = false - component.onQueryChanged("") - archiveOpen = false - if (selectedFolderId == ARCHIVE_FOLDER_ID) selectedFolderId = null - } + val callback = + BackCallback(isEnabled = viewingArchive) { + searchOpen = false + component.onQueryChanged("") + archiveOpen = false + if (selectedFolderId == ARCHIVE_FOLDER_ID) selectedFolderId = null + } component.backHandler.register(callback) onDispose { component.backHandler.unregister(callback) } } @@ -336,19 +348,20 @@ fun ChatsContent( val searchRetryLabel = stringResource(R.string.chats_search_retry) val foldersAtBottom = appearance.foldersAtBottom val showAllChats = appearance.showAllChats - val chipItems = remember(state.chats, folders, showMutedCounter, allChatsLabel, showAllChats) { - perfSpan("chips") { - FolderChips( - folderChipItems( - chats = state.chats, - folders = folders, - allChatsLabel = allChatsLabel, - showMutedCounter = showMutedCounter, - showAllChats = showAllChats, - ), - ) + val chipItems = + remember(state.chats, folders, showMutedCounter, allChatsLabel, showAllChats) { + perfSpan("chips") { + FolderChips( + folderChipItems( + chats = state.chats, + folders = folders, + allChatsLabel = allChatsLabel, + showMutedCounter = showMutedCounter, + showAllChats = showAllChats, + ), + ) + } } - } val showFolderChips = remember(chipItems) { showsFolderChipRow(chipItems.items) } val allChats = remember { ChatListSnapshot() } val shownChats = remember { ChatListSnapshot() } @@ -358,27 +371,30 @@ fun ChatsContent( shownChats.replace( filterChats( visibleChats(state.chats, folders, homeFolderId), - state.query - ) + state.query, + ), ) } - val archivedSlice = remember(state.chats) { - perfSpan("archiveSlice") { - visibleChats(state.chats, emptyList(), ARCHIVE_FOLDER_ID) + val archivedSlice = + remember(state.chats) { + perfSpan("archiveSlice") { + visibleChats(state.chats, emptyList(), ARCHIVE_FOLDER_ID) + } } - } perfSpan("archivedChats") { archivedChats.replace(filterChats(archivedSlice, state.query)) } - val archivedPreview = remember(archivedSlice) { - folderUnreadBadge(archivedSlice) - } - val archivedTitles = remember(archivedSlice, texts.untitled) { - archivePreviewTitles( - chats = archivedSlice, - untitledLabel = texts.untitled, - ) - } + val archivedPreview = + remember(archivedSlice) { + folderUnreadBadge(archivedSlice) + } + val archivedTitles = + remember(archivedSlice, texts.untitled) { + archivePreviewTitles( + chats = archivedSlice, + untitledLabel = texts.untitled, + ) + } // Counted for visibility only: the header never prints totals. val archivedCount = archivedSlice.size val layoutDirection = LocalLayoutDirection.current @@ -400,57 +416,72 @@ 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") + 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 } - } else Modifier val atTop by remember(paneListState) { derivedStateOf { paneListState.firstVisibleItemIndex == 0 && paneListState.firstVisibleItemScrollOffset <= AtTopTolerance } } - val rawSync = when { - paneEmpty && (state.loading || state.syncing) -> AppSyncStatus.Connecting - state.syncing || (state.loading && !paneEmpty) -> AppSyncStatus.Syncing - state.loadingMore -> AppSyncStatus.LoadingMore - else -> AppSyncStatus.Hidden - } - val syncStatus = rememberDebouncedSync( - status = rawSync, - immediate = rawSync == AppSyncStatus.Connecting || rawSync == AppSyncStatus.Syncing, - ) + val rawSync = + when { + paneEmpty && (state.loading || state.syncing) -> AppSyncStatus.Connecting + state.syncing || (state.loading && !paneEmpty) -> AppSyncStatus.Syncing + state.loadingMore -> AppSyncStatus.LoadingMore + else -> AppSyncStatus.Hidden + } + val syncStatus = + rememberDebouncedSync( + status = rawSync, + immediate = rawSync == AppSyncStatus.Connecting || rawSync == AppSyncStatus.Syncing, + ) val unreadChats = paneChats.unmutedUnread - val overlayTitle = when (syncStatus) { - AppSyncStatus.Connecting, AppSyncStatus.Syncing -> syncStatus.uiLabel() - else -> null - } - val subtitle = when { - overlayTitle != null -> null - state.error?.kind == TelegramError.Kind.Network -> - stringResource(R.string.chats_waiting_network) + val overlayTitle = + when (syncStatus) { + AppSyncStatus.Connecting, AppSyncStatus.Syncing -> syncStatus.uiLabel() + else -> null + } + val subtitle = + when { + overlayTitle != null -> { + null + } - unreadChats > 0 -> - pluralStringResource(R.plurals.chats_unread_chats, unreadChats, unreadChats) + state.error?.kind == TelegramError.Kind.Network -> { + stringResource(R.string.chats_waiting_network) + } - else -> null - } - val folderTitle = when { - archive -> archiveLabel - homeFolderId == null -> allChatsLabel - else -> folders.firstOrNull { it.id == homeFolderId }?.title ?: allChatsLabel - } + unreadChats > 0 -> { + pluralStringResource(R.plurals.chats_unread_chats, unreadChats, unreadChats) + } + + else -> { + null + } + } + val folderTitle = + when { + archive -> archiveLabel + homeFolderId == null -> allChatsLabel + else -> folders.firstOrNull { it.id == homeFolderId }?.title ?: allChatsLabel + } val markAllIds = remember(state.chats, archive) { unreadChatIds(state.chats, archive) } Scaffold( @@ -483,9 +514,10 @@ fun ChatsContent( ) { Icon( imageVector = if (searchOpen) Icons.Outlined.Close else Icons.Outlined.Search, - contentDescription = stringResource( - if (searchOpen) R.string.chats_search_close else R.string.chats_search, - ), + contentDescription = + stringResource( + if (searchOpen) R.string.chats_search_close else R.string.chats_search, + ), ) } }, @@ -500,13 +532,26 @@ fun ChatsContent( overlayTitle = overlayTitle, archive = archive, selfTitle = state.self?.title ?: stringResource(R.string.chats_profile), - selfAvatar = rememberSelfAvatar( - self = state.self, - mediaRepository = component.mediaRepository, - ), - selfPreview = rememberStrippedPreview(state.self?.id) { - state.self?.id?.let { component.mediaRepository?.peerInlineThumbJpeg(it) } - }, + selfAvatar = + rememberSelfAvatar( + self = state.self, + mediaRepository = component.mediaRepository, + ), + 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, searchOpen = searchOpen, @@ -523,15 +568,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 - }, + 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), @@ -546,12 +594,13 @@ fun ChatsContent( }, ) { inner -> Column( - modifier = Modifier - .fillMaxSize() - .padding( - top = inner.calculateTopPadding(), - bottom = inner.calculateBottomPadding(), - ), + modifier = + Modifier + .fillMaxSize() + .padding( + top = inner.calculateTopPadding(), + bottom = inner.calculateBottomPadding(), + ), ) { AnimatedVisibility( visible = searchOpen, @@ -571,16 +620,18 @@ fun ChatsContent( AppStatusBanner( sync = syncStatus, error = if (state.query.isNotBlank()) state.searchError else state.error, - onRetry = if (state.query.isNotBlank()) { - component::onRetrySearch - } else { - component::onRefresh - }, + onRetry = + if (state.query.isNotBlank()) { + component::onRetrySearch + } else { + component::onRefresh + }, ) Column( - modifier = Modifier - .weight(1f) - .fillMaxSize() + modifier = + Modifier + .weight(1f) + .fillMaxSize(), ) { if (!archive && !searchOpen && !foldersAtBottom && showFolderChips) { Box { @@ -611,11 +662,12 @@ fun ChatsContent( } } Box( - modifier = Modifier - .weight(1f) - .fillMaxSize() + modifier = + Modifier + .weight(1f) + .fillMaxSize(), ) { - androidx.compose.animation.AnimatedVisibility( + AnimatedVisibility( visible = state.loading && paneEmpty && !folderMoving, modifier = Modifier.fillMaxSize(), enter = fadeIn(animationSpec = tween(180)), @@ -700,26 +752,29 @@ fun ChatsContent( motionEnabled = listMotionEnabled(), onMoving = { folderMoving = it }, ) { folderId, isPrimary -> - val pageChats = if (folderId == homeFolderId) { - shownChats - } else { - remember { ChatListSnapshot() }.also { snapshot -> - snapshot.replace( - filterChats( - visibleChats(state.chats, folders, folderId), - state.query, - ), - ) + val pageChats = + if (folderId == homeFolderId) { + shownChats + } else { + remember { ChatListSnapshot() }.also { snapshot -> + snapshot.replace( + filterChats( + visibleChats(state.chats, folders, folderId), + state.query, + ), + ) + } } - } - val restored = clampFolderScroll( - folderScroll[folderScrollKey(folderId)], - pageChats.size, - ) - val pageListState = rememberLazyListState( - initialFirstVisibleItemIndex = restored.index, - initialFirstVisibleItemScrollOffset = restored.offset, - ) + val restored = + clampFolderScroll( + folderScroll[folderScrollKey(folderId)], + pageChats.size, + ) + val pageListState = + rememberLazyListState( + initialFirstVisibleItemIndex = restored.index, + initialFirstVisibleItemScrollOffset = restored.offset, + ) SideEffect { if (isPrimary) primaryListState = pageListState } @@ -737,7 +792,8 @@ fun ChatsContent( archivedTitles = archivedTitles, archivedUnmuted = archivedPreview.unmuted, archivedMuted = if (showMutedCounter) archivedPreview.muted else 0, - showArchiveRow = folderId == null && !searchOpen && + showArchiveRow = + folderId == null && !searchOpen && state.query.isBlank() && archivedCount > 0, loading = state.loading && activePage, error = state.error, @@ -870,11 +926,12 @@ private fun FloatingFolderBar( val miniPlayerPad = if (MediaPlaybackHolder.session(context).showsMiniPlayer()) 72.dp else 0.dp val barColor = MaterialTheme.colorScheme.surfaceContainerHigh Surface( - modifier = modifier - .fillMaxWidth() - .wrapContentHeight() - .navigationBarsPadding() - .padding(start = 12.dp, end = 12.dp, bottom = 12.dp + miniPlayerPad), + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .navigationBarsPadding() + .padding(start = 12.dp, end = 12.dp, bottom = 12.dp + miniPlayerPad), shape = MaterialTheme.shapes.extraLarge, color = barColor, tonalElevation = 3.dp, @@ -920,13 +977,16 @@ internal fun FolderChipMenu( onMarkRead: (List) -> Unit, onEditFolders: () -> Unit, ) { - val unread = remember(folderId, chats, folders) { - if (folderId == null) emptyList() else { - visibleChats(chats, folders, folderId) - .filter { it.unreadCount > 0 } - .map { it.id } + val unread = + remember(folderId, chats, folders) { + if (folderId == null) { + emptyList() + } else { + visibleChats(chats, folders, folderId) + .filter { it.unreadCount > 0 } + .map { it.id } + } } - } AppMenuSurface { AppMenuGroup { AppMenuItem( @@ -949,32 +1009,33 @@ private fun rememberSelfAvatar( self: Profile?, mediaRepository: MediaRepository?, ) = rememberEnsuredFile( - generation = rememberCacheGeneration( - remember(mediaRepository, self?.id, self?.avatarCacheKey) { - self?.id?.let { - mediaRepository?.cacheGeneration( - peerAvatarCacheKey( - it, - self.avatarCacheKey + generation = + rememberCacheGeneration( + remember(mediaRepository, self?.id, self?.avatarCacheKey) { + self?.id?.let { + mediaRepository?.cacheGeneration( + peerAvatarCacheKey( + it, + self.avatarCacheKey, + ), ) - ) - } - }, - ), + } + }, + ), 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, - ) + val result = + repo.ensureLocalAvatar( + peer, + peerAvatarCacheKey(peer, self.avatarCacheKey), + MediaPriority.THUMB, + ) ) { is Outcome.Ok -> result.value is Outcome.Err -> null @@ -990,8 +1051,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 @@ -1013,20 +1080,22 @@ private fun filterChats( /** Scroll slop that still counts as "the list is at the top". */ private const val AtTopTolerance = 8 -private val FolderScrollSaver = listSaver, Int>( - save = { entries -> - entries.flatMap { (id, scroll) -> - listOf( - id, - scroll.index, - scroll.offset - ) - } - }, - restore = { values -> - values.chunked(3).associate { (id, index, offset) -> id to FolderListScroll(index, offset) } - }, -) +private val FolderScrollSaver = + listSaver, Int>( + save = { entries -> + entries.flatMap { (id, scroll) -> + listOf( + id, + scroll.index, + scroll.offset, + ) + } + }, + restore = { values -> + values.chunked(3) + .associate { (id, index, offset) -> id to FolderListScroll(index, offset) } + }, + ) @Composable internal fun FolderEmptyState( @@ -1062,42 +1131,47 @@ internal fun FolderEmptyState( animationSpec = if (motion) enterDp else snap(), label = "emptyTextShift", ) - val icon = when { - archive -> Icons.Outlined.Inventory2 - query.isNotBlank() -> Icons.Outlined.SearchOff - else -> Icons.Outlined.Forum - } - val title = when { - selectingRecipient && query.isBlank() -> stringResource(R.string.chats_share_empty) - archive -> stringResource(R.string.chats_archive_empty) - query.isNotBlank() -> stringResource(R.string.chats_search_empty) - filtered -> stringResource(R.string.chats_folder_empty) - else -> stringResource(R.string.chats_empty) - } - val body = when { - selectingRecipient && query.isBlank() -> stringResource(R.string.chats_share_empty_body) - archive -> stringResource(R.string.chats_archive_empty_body) - query.isNotBlank() -> stringResource(R.string.chats_search_empty_body, query) - filtered -> stringResource(R.string.chats_folder_empty_body) - else -> stringResource(R.string.chats_empty_body) - } + val icon = + when { + archive -> Icons.Outlined.Inventory2 + query.isNotBlank() -> Icons.Outlined.SearchOff + else -> Icons.Outlined.Forum + } + val title = + when { + selectingRecipient && query.isBlank() -> stringResource(R.string.chats_share_empty) + archive -> stringResource(R.string.chats_archive_empty) + query.isNotBlank() -> stringResource(R.string.chats_search_empty) + filtered -> stringResource(R.string.chats_folder_empty) + else -> stringResource(R.string.chats_empty) + } + val body = + when { + selectingRecipient && query.isBlank() -> stringResource(R.string.chats_share_empty_body) + archive -> stringResource(R.string.chats_archive_empty_body) + query.isNotBlank() -> stringResource(R.string.chats_search_empty_body, query) + filtered -> stringResource(R.string.chats_folder_empty_body) + else -> stringResource(R.string.chats_empty_body) + } Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Surface( color = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, shape = RoundedCornerShape(32.dp), - modifier = Modifier - .size(96.dp) - .graphicsLayer { - scaleX = badgeScale - scaleY = badgeScale - alpha = badgeAlpha - }, + modifier = + Modifier + .size(96.dp) + .graphicsLayer { + scaleX = badgeScale + scaleY = badgeScale + alpha = badgeAlpha + }, ) { Box(contentAlignment = Alignment.Center) { Icon(icon, null, Modifier.size(40.dp)) @@ -1105,10 +1179,11 @@ internal fun FolderEmptyState( } Spacer(Modifier.height(20.dp)) Column( - modifier = Modifier.graphicsLayer { - alpha = textAlpha - translationY = textShift.toPx() - }, + modifier = + Modifier.graphicsLayer { + alpha = textAlpha + translationY = textShift.toPx() + }, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( 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..c46726eb1 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,18 +82,19 @@ internal fun DialogTopBar( onDeleteSelectedMessages: () -> Unit, ) { val hasScheduledMessages by component.hasScheduledMessages.collectAsStateWithLifecycle( - initialValue = false + initialValue = false, ) if (selectedMessageCount > 0) { TopAppBar( windowInsets = WindowInsets.statusBars, title = { Text( - text = pluralStringResource( - R.plurals.dialog_messages_selected, - selectedMessageCount, - selectedMessageCount, - ), + text = + pluralStringResource( + R.plurals.dialog_messages_selected, + selectedMessageCount, + selectedMessageCount, + ), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -125,9 +126,10 @@ internal fun DialogTopBar( ) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), ) return } @@ -152,34 +154,43 @@ internal fun DialogTopBar( modifier = Modifier.clickable(onClick = component::onOpenProfile), ) { val cacheGeneration = - component.mediaRepository?.cacheGeneration?.collectAsState()?.value + component.mediaRepository + ?.cacheGeneration + ?.collectAsState() + ?.value ?: 0L - val chatAvatar = rememberEnsuredFile( - generation = cacheGeneration, - identity = state.chatId.value to state.photoCacheKey, - resolve = { - state.photoCacheKey?.let { component.mediaRepository?.cachedFile(it) } - ?: component.mediaRepository?.cachedAvatar(state.chatId) - }, - ensure = { - val repo = - component.mediaRepository ?: return@rememberEnsuredFile null - val key = peerAvatarCacheKey( - state.chatId, - state.photoCacheKey, - ) - when ( - val result = repo.ensureLocalAvatar( - state.chatId, - key, - priority = MediaPriority.VISIBLE, - ) - ) { - is Outcome.Ok -> result.value - is Outcome.Err -> null - } - }, - ) + val chatAvatar = + rememberEnsuredFile( + generation = cacheGeneration, + identity = state.chatId.value to state.photoCacheKey, + resolve = { + state.photoCacheKey?.let { + component.mediaRepository?.cachedFile( + it + ) + } + }, + ensure = { + val repo = + component.mediaRepository ?: return@rememberEnsuredFile null + val key = + peerAvatarCacheKey( + state.chatId, + state.photoCacheKey, + ) + when ( + val result = + repo.ensureLocalAvatar( + state.chatId, + key, + priority = MediaPriority.VISIBLE, + ) + ) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + }, + ) if (state.inForumTopic) { TopicIcon( org.monogram.core.models.ForumTopic( @@ -194,9 +205,19 @@ 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 + ) + }, ) } Column(modifier = Modifier.weight(1f)) { @@ -226,17 +247,19 @@ internal fun DialogTopBar( status = state.peerStatus, statusAt = state.peerStatusAt, onExpired = component::onRefreshPresence, - typing = displayedChatAction( - state.typingUsers.values, - named = state.isGroup || state.isChannel, - ), + typing = + displayedChatAction( + state.typingUsers.values, + named = state.isGroup || state.isChannel, + ), syncLabel = syncStatus.uiLabel(), - stats = chatStatsSubtitle( - isChannel = state.isChannel, - isGroup = state.isGroup, - members = state.membersCount, - online = state.onlineCount, - ), + stats = + chatStatsSubtitle( + isChannel = state.isChannel, + isGroup = state.isGroup, + members = state.membersCount, + online = state.onlineCount, + ), channelLabel = stringResource(R.string.dialog_channel), groupLabel = stringResource(R.string.dialog_group), isChannel = state.isChannel, @@ -246,11 +269,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,26 +287,30 @@ 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, ) { Icon( imageVector = if (showSearch) Icons.Outlined.Close else Icons.Outlined.Search, - contentDescription = stringResource( - if (showSearch) R.string.dialog_search_close else R.string.dialog_search, - ), + contentDescription = + stringResource( + if (showSearch) R.string.dialog_search_close else R.string.dialog_search, + ), ) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), ) DialogDownloadProgressBar( downloads = userDownloads, @@ -319,18 +348,20 @@ 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 { - isChannel -> channelLabel - isGroup -> groupLabel - else -> null - } + val fallback = + syncLabel + ?: peerStatusLabel(presenceStatus, presenceAt, presenceNow) + ?: stats + ?: when { + isChannel -> channelLabel + isGroup -> groupLabel + else -> null + } AnimatedContent( targetState = typing?.action to (typing?.names ?: emptyList()) to fallback, transitionSpec = { - val typingNow = targetState.first.first != null || + val typingNow = + targetState.first.first != null || initialState.first.first != null if (typingNow) { liveActionTransition() @@ -363,11 +394,12 @@ private fun DialogStatusLine( Text( text = statusText, style = MaterialTheme.typography.labelMedium, - color = if (syncLabel != null) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + color = + if (syncLabel != null) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, maxLines = 1, ) } 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..4cf3c88fb 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 @@ -44,17 +44,18 @@ fun ForwardPickerSheet( onSelect: (PeerId) -> Unit, onDismiss: () -> Unit, ) { - val shown = remember(targets, query) { - val q = query.trim() - if (q.isEmpty()) { - targets - } else { - targets.filter { - it.title.contains(q, ignoreCase = true) || - it.lastMessagePreview.orEmpty().contains(q, ignoreCase = true) + val shown = + remember(targets, query) { + val q = query.trim() + if (q.isEmpty()) { + targets + } else { + targets.filter { + it.title.contains(q, ignoreCase = true) || + it.lastMessagePreview.orEmpty().contains(q, ignoreCase = true) + } } } - } AppModalSheet( onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surfaceContainerLow, @@ -65,51 +66,56 @@ 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, - 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()) { + Column( + modifier = + Modifier + .fillMaxWidth() + .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), ) - } 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) }, - ) + 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), + ) + 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) }, + ) + } } } } } - } } } @@ -122,25 +128,27 @@ private fun ForwardChatRow( ) { val cacheGeneration = mediaRepository?.cacheGeneration?.collectAsState()?.value ?: 0L val key = peerAvatarCacheKey(chat.id, chat.photoCacheKey) - val avatarFile = rememberEnsuredFile( - generation = cacheGeneration, - identity = chat.id.value to key, - resolve = { - mediaRepository?.cachedFile(key) ?: mediaRepository?.cachedAvatar(chat.id) - }, - ensure = { - val repo = mediaRepository ?: return@rememberEnsuredFile null - when (val result = repo.ensureLocalAvatar(chat.id, key)) { - is Outcome.Ok -> result.value - is Outcome.Err -> null - } - }, - ) + val avatarFile = + rememberEnsuredFile( + generation = cacheGeneration, + identity = chat.id.value to key, + resolve = { + mediaRepository?.cachedFile(key) + }, + ensure = { + val repo = mediaRepository ?: return@rememberEnsuredFile null + when (val result = repo.ensureLocalAvatar(chat.id, key)) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + }, + ) Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 16.dp, vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -148,9 +156,10 @@ 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)) { Text( 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 7bf524615..15148912d 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 @@ -70,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 @@ -281,7 +280,6 @@ fun MessageBubble( 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 @@ -298,8 +296,19 @@ fun MessageBubble( size = 28.dp, imageFile = senderAvatar, previewBytes = - rememberStrippedPreview(sender?.id) { - sender?.id?.let { mediaRepository?.peerInlineThumbJpeg(it) } + rememberStrippedPreview( + Triple( + mediaRepository, + sender?.id, + sender?.avatarCacheKey + ) + ) { + sender?.id?.let { + mediaRepository?.peerInlineThumbJpeg( + it, + sender?.avatarCacheKey + ) + } }, modifier = Modifier @@ -895,7 +904,7 @@ fun MessageBubble( if (mediaCaption && !mediaOverlayMeta && message.text != message.checklist?.title) { var textLayoutInfo by remember( message.id, - renderedBlocks + renderedBlocks, ) { mutableStateOf(null) } val hasWideChrome = message.replyToMsgId != null || !quote.isNullOrBlank() || message.mediaKind != null || showSender || 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 0d38bcd46..681fd0921 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 @@ -172,15 +172,26 @@ fun SettingsContent( component.mediaRepository?.cacheGeneration( listOf( peerAvatarCacheKey(peer, profile.avatarCacheKey), - peerAvatarCacheKey(peer) + peerAvatarCacheKey(peer), ), ) } }, ) val selfAvatarPreview = - rememberStrippedPreview(profile?.id) { - profile?.id?.let { component.mediaRepository?.peerInlineThumbJpeg(it) } + rememberStrippedPreview( + Triple( + component.mediaRepository, + profile?.id, + profile?.avatarCacheKey + ) + ) { + profile?.id?.let { + component.mediaRepository?.peerInlineThumbJpeg( + it, + profile?.avatarCacheKey + ) + } } val avatarFile = rememberEnsuredFile( @@ -188,7 +199,6 @@ fun SettingsContent( 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 @@ -810,8 +820,10 @@ private fun SettingsPageList( start = 16.dp, top = innerPadding.calculateTopPadding() + 8.dp, end = 16.dp, - bottom = WindowInsets.navigationBars.asPaddingValues() - .calculateBottomPadding() + 16.dp, + bottom = + WindowInsets.navigationBars + .asPaddingValues() + .calculateBottomPadding() + 16.dp, ), verticalArrangement = Arrangement.spacedBy(0.dp), content = content, 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 1b3f545d6..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 @@ -539,7 +539,8 @@ private fun SettingsAccountAvatar( 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) } + val preview = + rememberStrippedPreview(identity) { repo.peerInlineThumbJpeg(peerId, avatarCacheKey) } val file = rememberEnsuredFile( generation = generation, 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 a59e3c3a5..5fa7fceec 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 @@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.merge @@ -222,12 +221,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() } @@ -275,8 +277,10 @@ class MediaRepository( .map { key -> key to keyedGenerations.compute(key) { _, current -> - (current - ?: KeyedGeneration()).also { it.collectors.incrementAndGet() } + ( + current + ?: KeyedGeneration() + ).also { it.collectors.incrementAndGet() } }!! }.toList() try { @@ -930,7 +934,7 @@ class MediaRepository( cache.reserve( key, it, - optional = false + optional = false, ) }.getOrNull() } diff --git a/vendor/mono-rs b/vendor/mono-rs index 3a4c49a70..5cefccb32 160000 --- a/vendor/mono-rs +++ b/vendor/mono-rs @@ -1 +1 @@ -Subproject commit 3a4c49a70e8723baf4499ab1f1e0ec5d289198f8 +Subproject commit 5cefccb327584f6fb0094e3fa617a976d7892fc2 From 22f1335b5d517dbb57215bc118d043f6e413378f Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 14 Nov 2026 21:50:41 +0300 Subject: [PATCH 8/9] fix(media): harden media caching --- app/src/main/java/org/monogram/MonogramApp.kt | 1021 +++++---- .../java/org/monogram/root/RootComponent.kt | 553 +++-- .../java/org/monogram/root/RootContent.kt | 240 +- .../11.json | 1929 +++++++++++++++++ .../database/ScheduledQueuePersistenceTest.kt | 587 +++-- .../core/database/DatabaseProvider.kt | 28 +- .../monogram/core/database/ModelMappers.kt | 564 +++-- .../core/database/MonogramDatabase.kt | 2 +- .../monogram/core/database/OfflineWarmup.kt | 1550 ++++++------- .../core/database/SessionMetadataStore.kt | 4 +- .../core/database/entity/ChatEntity.kt | 4 +- .../core/database/entity/PeerEntity.kt | 2 + .../core/database/MessageMediaFieldsTest.kt | 169 +- .../monogram/core/models/ChatIncomingMerge.kt | 18 +- .../java/org/monogram/core/models/Models.kt | 45 +- .../org/monogram/core/ui/CurrentLocation.kt | 108 +- .../core/ui/components/AppModalSheet.kt | 66 +- .../core/ui/components/LoopingVideo.kt | 17 +- .../monogram/core/ui/components/PeerAvatar.kt | 111 +- .../core/ui/components/SettingsTile.kt | 187 +- .../core/ui/media/MediaCaptionSheet.kt | 331 ++- .../core/ui/media/MediaPlaybackSession.kt | 133 +- .../core/ui/media/MediaViewerModel.kt | 64 +- .../core/ui/media/VoiceWaveformExtraction.kt | 138 +- .../core/ui/perf/RecompositionProbe.kt | 52 +- .../monogram/feature/chats/ChatsExecutor.kt | 886 ++++---- .../org/monogram/feature/chats/ChatsMerge.kt | 131 +- .../monogram/feature/chats/ui/ChatsContent.kt | 574 +++-- .../feature/dialog/DialogComponent.kt | 291 ++- .../feature/dialog/DialogNavigation.kt | 171 +- .../monogram/feature/dialog/DialogStore.kt | 310 ++- .../monogram/feature/dialog/PickerCache.kt | 144 +- .../feature/dialog/store/DialogExecutor.kt | 867 +++++--- .../feature/dialog/store/DialogForum.kt | 40 +- .../feature/dialog/store/DialogHistory.kt | 83 +- .../feature/dialog/store/DialogStickers.kt | 82 +- .../feature/dialog/ui/AudioMessagePlayback.kt | 197 +- .../feature/dialog/ui/ChatInlineVideo.kt | 11 +- .../feature/dialog/ui/DialogTopBar.kt | 171 +- .../feature/dialog/ui/ForwardPicker.kt | 81 +- .../feature/dialog/ui/MessageBubble.kt | 839 ++++--- .../feature/dialog/ui/MessageMedia.kt | 155 +- .../feature/dialog/ui/MessageMediaViewer.kt | 788 ++++--- .../feature/dialog/ui/MessageSeenRow.kt | 110 +- .../feature/profile/ui/ProfileContent.kt | 71 +- .../feature/profile/ui/ProfileHeader.kt | 64 +- .../feature/profile/ui/ProfileMembers.kt | 29 +- .../feature/settings/ui/SettingsContent.kt | 427 ++-- native/Cargo.lock | 2 +- native/mtproto-rs/src/dto.rs | 8 + native/mtproto-rs/src/ffi.rs | 27 + .../monogram/mtproto/NativeApiContractTest.kt | 333 +-- .../org/monogram/mtproto/MtprotoNative.kt | 715 ++++-- .../monogram/mtproto/MtprotoNativeUniFfi.kt | 734 +++++-- .../monogram_mtproto/monogram_mtproto.kt | 115 +- .../monogram/network/bridge/chat/Mappings.kt | 1 + .../monogram/network/bridge/media/MediaApi.kt | 143 +- .../monogram/network/bridge/media/MediaOps.kt | 47 +- .../network/bridge/profile/Mappings.kt | 1 + .../bridge/updates/UpdateEventMapping.kt | 172 +- .../network/bridge/updates/UpdatesPump.kt | 320 ++- .../monogram/network/http/MediaRepository.kt | 926 ++++---- .../network/http/TelegramVideoDataSource.kt | 88 +- vendor/mono-rs | 2 +- 64 files changed, 11131 insertions(+), 6948 deletions(-) create mode 100644 core/database/schemas/org.monogram.core.database.MonogramDatabase/11.json diff --git a/app/src/main/java/org/monogram/MonogramApp.kt b/app/src/main/java/org/monogram/MonogramApp.kt index 7b63f271e..871ac1270 100644 --- a/app/src/main/java/org/monogram/MonogramApp.kt +++ b/app/src/main/java/org/monogram/MonogramApp.kt @@ -43,6 +43,7 @@ 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 @@ -53,6 +54,8 @@ 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 @@ -122,24 +125,22 @@ class MonogramApp : Application() { 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, - ) - }, - ) + 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 @@ -155,37 +156,35 @@ class MonogramApp : Application() { ) draft.start(scope) outgoing.start(scope) - updateJob = - scope.launch { - runtime.client.updates().collect { update -> - when (update) { - is MtprotoUpdate.ChatsChanged -> { - runtime.warmup.upsertChats( - update.chats, - ) - } + 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() - } + is MtprotoUpdate.SyncState -> { + if (!update.isSyncing) resume() + } - else -> { - Unit - } + 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() - } + } + 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() { @@ -231,8 +230,7 @@ class MonogramApp : Application() { this, allowCached = false, )?.let { - org.monogram.core.models - .GeoPlace(it.first, it.second) + GeoPlace(it.first, it.second) } }, permitted = { ContextCompat.checkSelfPermission( @@ -268,14 +266,13 @@ class MonogramApp : Application() { private fun updateActivityForeground() { activityIdleJob?.cancel() - activityIdleJob = - activityScope.launch { - awaitReady() - val foreground = startedActivities.isNotEmpty() - val activeUserId = accountRegistry.activeUserId - if (activeUserId == null) push.setForeground(foreground) - pushRouter.setForeground(foreground, activeUserId) - } + activityIdleJob = activityScope.launch { + awaitReady() + val foreground = startedActivities.isNotEmpty() + val activeUserId = accountRegistry.activeUserId + if (activeUserId == null) push.setForeground(foreground) + pushRouter.setForeground(foreground, activeUserId) + } } private suspend fun startAccountPushes(readyUserIds: Set? = null) { @@ -286,27 +283,25 @@ class MonogramApp : Application() { 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 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, - ) + 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() @@ -371,20 +366,17 @@ class MonogramApp : Application() { 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(), - ) + 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, @@ -419,35 +411,30 @@ class MonogramApp : Application() { } } - 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) + 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?, @@ -461,105 +448,99 @@ class MonogramApp : Application() { 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, - ) + 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 { - 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 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 -> - 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) + 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") } - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - AppLog.warn("account", "remaining account connection failed unexpectedly") + applyDownloadSettings(nextRuntime.client, DownloadSettings.state.value) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("account", "remaining account connection failed unexpectedly") } - startAccountPushes() } + startAccountPushes() + } private fun bindActiveRuntime(runtime: AccountRuntime) { client = runtime.client mediaRepository = accountMedia(runtime) - notifications = - NotificationLocalStore( - this, - runtime.account.userId, - migrateLegacy = runtime.account.databaseName == "monogram.db", - ) + notifications = NotificationLocalStore( + this, + runtime.account.userId, + migrateLegacy = runtime.account.databaseName == "monogram.db", + ) sessionStore = runtime.sessionStore warmup = runtime.warmup push = accountPushes[runtime.account.userId] ?: push @@ -567,53 +548,49 @@ class MonogramApp : Application() { 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 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)) - } + 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() }, @@ -624,37 +601,30 @@ class MonogramApp : Application() { ), ) currentCoroutineContext().ensureActive() - if (!runtime.isValid() || runtime.status == AccountRuntimeStatus.SignInRequired || !runtime.sessionStore.isAuthorized() || - runtime.sessionStore.readAuthorizedUserId()?.value != userId - ) { + 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, - ) + 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) } @@ -677,99 +647,123 @@ class MonogramApp : Application() { pushRouter.handleFcm(payload, onComplete) } - fun awaitReadyBlocking(timeoutMs: Long = 20_000L): Boolean = - runBlocking { - try { - withTimeout(timeoutMs) { ready.await() } - true - } catch (_: Exception) { - false - } + fun awaitReadyBlocking(timeoutMs: Long = 20_000L): Boolean = runBlocking { + try { + withTimeout(timeoutMs) { ready.await() } + true + } catch (_: Exception) { + false } + } - fun awaitPushReadyBlocking(timeoutMs: Long = 20_000L): Boolean = - runBlocking { - try { - withTimeout(timeoutMs) { pushReady.await() } - true - } catch (_: Exception) { - false - } + 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() - } + 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 = - TelegramChunkFetcher { chatId, messageId, path, offset -> - client.downloadMessageMediaChunk(chatId, messageId, path, offset) - }, - telegramFetcher = - object : TelegramMediaFetcher { - override fun promote( - destPath: String, - priority: Int, - ) = client.promoteDownload(destPath, priority) + 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 fetchMessageMedia( - chatId: PeerId, - messageId: Int, - destPath: String, - kind: MediaFetchKind, - priority: Int, - ): Outcome = - when (kind) { - MediaFetchKind.Thumb -> { - client.downloadMessageThumb( - chatId, - messageId, - destPath, - priority, - ) - } + 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 + ) - MediaFetchKind.Display -> { - client.downloadMessageDisplay( - chatId, - messageId, - destPath, - priority, - ) - } + override fun promote( + destPath: String, + priority: Int, + ) = client.promoteDownload(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, - ) + 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) } @@ -858,20 +852,18 @@ class MonogramApp : Application() { } private fun firstPaintSteps(): StartupSteps { - credentials = - TelegramCredentials( - apiId = BuildConfig.TELEGRAM_API_ID, - apiHash = BuildConfig.TELEGRAM_API_HASH, - ) + 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 currentAccount(): AccountRecord? = if (::accountRegistry.isInitialized) { + accountRegistry.activeUserId?.let(accountRegistry::get) + } else { + null + } fun currentMediaRoot(): File = currentAccount()?.mediaPath ?: durableMedia lateinit var db: MonogramDatabase @@ -880,22 +872,19 @@ class MonogramApp : Application() { openDatabase = { 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) - } + db = perfSpan("app:database") { + if (account == null || account.databaseName == "monogram.db") { + DatabaseProvider.get(this) + } else { + DatabaseProvider.get(this, account.userId) } - warmup = - OfflineWarmup( - db, - outgoingMediaFiles = - OutgoingMediaFiles( - File(noBackupFilesDir, "outgoing-media"), - ), - ) + } + warmup = OfflineWarmup( + db, + outgoingMediaFiles = OutgoingMediaFiles( + File(noBackupFilesDir, "outgoing-media"), + ), + ) sessionStore = SessionMetadataStore(db) }, installAppearance = { @@ -915,17 +904,15 @@ class MonogramApp : Application() { }.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) - } + accountRuntimeManager = AccountRuntimeManager( + context = this, + registry = accountRegistry, + credentials = credentials, + initConnection = monogramInitConnection(), + ) + val runtime = currentAccount()?.let { + accountRuntimeManager.switchTo(it.userId) + } client = runtime?.client ?: perfSpan("app:client") { BridgedMtprotoClient( credentials = credentials, @@ -938,29 +925,27 @@ class MonogramApp : Application() { 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 - } - }.getOrDefault(false) + 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 + } + }.getOrDefault(false) if (!applied) { runCatching { client.clearProxy() } runCatching { client.setTransportMode(MtprotoTransportMode.PADDED_INTERMEDIATE) } @@ -1040,9 +1025,7 @@ class MonogramApp : Application() { mediaMigration = { TdlibSessionImport.wipeTdlib(filesDir, cacheDir) val durableMedia = currentMediaRoot() - if ((currentAccount() == null || currentAccount()?.databaseName == "monogram.db") && !durableMedia.exists() && - cacheMedia.exists() - ) { + if ((currentAccount() == null || currentAccount()?.databaseName == "monogram.db") && !durableMedia.exists() && cacheMedia.exists()) { runCatching { cacheMedia.copyRecursively( durableMedia, @@ -1062,10 +1045,9 @@ class MonogramApp : Application() { val durableMedia = currentMediaRoot() val client = client val warmup = warmup - mediaRepository = - perfSpan("app:mediaRepository") { - createMediaRepository(client, durableMedia) - } + mediaRepository = perfSpan("app:mediaRepository") { + createMediaRepository(client, durableMedia) + } val accountId = currentAccount()?.userId if (accountId != null) { accountMediaRepositories[accountId] = mediaRepository @@ -1085,28 +1067,26 @@ class MonogramApp : Application() { } }, createPush = { - notifications = - NotificationLocalStore( + notifications = NotificationLocalStore( + this, + currentAccount()?.userId, + migrateLegacy = currentAccount()?.databaseName == "monogram.db", + ) + push = perfSpan("app:push") { + PushCoordinator( this, - currentAccount()?.userId, - migrateLegacy = currentAccount()?.databaseName == "monogram.db", + client, + notifications, + mediaRepository, + DefaultStoreFactory(), + sessionStore, + otherAccountUserIds = { + accountRegistry.all().map { it.userId } + }, + sharedPushSecret = sharedPushSecret::get, + notificationCache = warmup, ) - push = - perfSpan("app:push") { - PushCoordinator( - this, - client, - notifications, - mediaRepository, - DefaultStoreFactory(), - sessionStore, - otherAccountUserIds = { - accountRegistry.all().map { it.userId } - }, - sharedPushSecret = sharedPushSecret::get, - notificationCache = warmup, - ) - } + } pushRouter = AccountPushRouter() currentAccount()?.userId?.let { userId -> push.setAccountUserId(userId) @@ -1114,54 +1094,52 @@ class MonogramApp : Application() { accountPushes[userId] = push pushRouterCoordinatorIds += userId } - sponsorSync = - SponsorSyncManager( - settingsScope, - client, - db.sponsorDao(), - sessionStore, - ) - appUpdate = - AppUpdateManager( - scope = settingsScope, - isAuthorized = { - resolveStartOnHome( - sessionAuthorized = { sessionStore.isAuthorized() }, - locallyAuthorized = { client.isLocallyAuthorized() }, - explicitlySignedOut = { - sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0" - }, - ) - }, - betaUpdates = { AppUpdateSettings.betaUpdates.value }, - isSupporter = { - val id = sessionStore.readAuthorizedUserId()?.value - id != null && id in SponsorRegistry.sponsorIds.value - }, - resolveUsername = { username -> - when (val outcome = client.resolveUsername(username)) { - is Outcome.Ok -> Outcome.Ok(outcome.value.peerId.value) - is Outcome.Err -> outcome - } - }, - getHistoryPage = { chatId, limit, offsetId -> - client.getHistoryPage(PeerId(chatId), limit, offsetId) - }, - telegramDownload = { message -> - mediaRepository.ensureLocalMessageMedia( - message, - MediaPriority.USER, - ) - }, - cancelDownloadKey = { key -> mediaRepository.cancel(key) }, - progress = mediaRepository.downloadProgress, - currentVersionCode = { BuildConfig.VERSION_CODE }, - currentCommit = { BuildConfig.GIT_COMMIT }, - buildType = BuildConfig.BUILD_TYPE, - supportedAbis = { Build.SUPPORTED_ABIS.toList() }, - installer = AndroidAppUpdateInstaller(this), - updateDir = File(cacheDir, "updates"), - ) + sponsorSync = SponsorSyncManager( + settingsScope, + client, + db.sponsorDao(), + sessionStore, + ) + appUpdate = AppUpdateManager( + scope = settingsScope, + isAuthorized = { + resolveStartOnHome( + sessionAuthorized = { sessionStore.isAuthorized() }, + locallyAuthorized = { client.isLocallyAuthorized() }, + explicitlySignedOut = { + sessionStore.readMeta(SessionMetadataStore.KEY_AUTHORIZED) == "0" + }, + ) + }, + betaUpdates = { AppUpdateSettings.betaUpdates.value }, + isSupporter = { + val id = sessionStore.readAuthorizedUserId()?.value + id != null && id in SponsorRegistry.sponsorIds.value + }, + resolveUsername = { username -> + when (val outcome = client.resolveUsername(username)) { + is Outcome.Ok -> Outcome.Ok(outcome.value.peerId.value) + is Outcome.Err -> outcome + } + }, + getHistoryPage = { chatId, limit, offsetId -> + client.getHistoryPage(PeerId(chatId), limit, offsetId) + }, + telegramDownload = { message -> + mediaRepository.ensureLocalMessageMedia( + message, + MediaPriority.USER, + ) + }, + cancelDownloadKey = { key -> mediaRepository.cancel(key) }, + progress = mediaRepository.downloadProgress, + currentVersionCode = { BuildConfig.VERSION_CODE }, + currentCommit = { BuildConfig.GIT_COMMIT }, + buildType = BuildConfig.BUILD_TYPE, + supportedAbis = { Build.SUPPORTED_ABIS.toList() }, + installer = AndroidAppUpdateInstaller(this), + updateDir = File(cacheDir, "updates"), + ) }, startPush = { runCatching { push.start() }.onFailure { @@ -1173,28 +1151,26 @@ class MonogramApp : Application() { }, 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 + 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()) { @@ -1243,10 +1219,9 @@ class MonogramApp : Application() { val locale = Locale.getDefault() return ClientInitInfo( deviceModel = initDeviceModel(), - systemVersion = - Build.VERSION.RELEASE.orEmpty().ifBlank { - "SDK ${Build.VERSION.SDK_INT}" - }, + systemVersion = Build.VERSION.RELEASE.orEmpty().ifBlank { + "SDK ${Build.VERSION.SDK_INT}" + }, appVersion = BuildConfig.VERSION_NAME.ifBlank { "0" }, systemLangCode = locale.language.ifBlank { "en" }, langPack = "android", @@ -1280,21 +1255,17 @@ private fun isEmulatorDevice(model: String): Boolean { val fingerprint = Build.FINGERPRINT.orEmpty() val product = Build.PRODUCT.orEmpty() val hardware = Build.HARDWARE.orEmpty() - return fingerprint.startsWith("generic") || - fingerprint.contains( - "emulator", - ignoreCase = true, - ) || product.contains("sdk", ignoreCase = true) || - hardware.contains( - "goldfish", - ignoreCase = true, - ) || hardware.contains("ranchu", ignoreCase = true) || - model.contains( - "sdk_gphone", - ignoreCase = true, - ) || model.contains("emulator", ignoreCase = true) || - model.contains( - "google_sdk", - ignoreCase = true, - ) + return fingerprint.startsWith("generic") || fingerprint.contains( + "emulator", + ignoreCase = true, + ) || product.contains("sdk", ignoreCase = true) || hardware.contains( + "goldfish", + ignoreCase = true, + ) || hardware.contains("ranchu", ignoreCase = true) || model.contains( + "sdk_gphone", + ignoreCase = true, + ) || model.contains("emulator", ignoreCase = true) || model.contains( + "google_sdk", + ignoreCase = true, + ) } diff --git a/app/src/main/java/org/monogram/root/RootComponent.kt b/app/src/main/java/org/monogram/root/RootComponent.kt index b9290a40c..435d8f2b6 100644 --- a/app/src/main/java/org/monogram/root/RootComponent.kt +++ b/app/src/main/java/org/monogram/root/RootComponent.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.monogram.BuildConfig -import org.monogram.MonogramApp +import org.monogram.MonogramApp.PendingAccountAuthorization import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome import org.monogram.core.common.push.NotificationLocalStore @@ -46,7 +46,9 @@ 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.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 @@ -81,8 +83,8 @@ class RootComponent( private val onAccountLoggedOut: suspend () -> Unit = {}, private val onAccountWorkStop: suspend () -> Unit = {}, private val onAccountWorkResume: () -> Unit = {}, - private val onBeginAddAccount: suspend () -> MonogramApp.PendingAccountAuthorization = { error("Add account unavailable") }, - private val onAdditionalAccountAuthorized: suspend (AuthSession, MonogramApp.PendingAccountAuthorization) -> 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(), @@ -93,9 +95,8 @@ class RootComponent( private val onSwitchAccount: suspend (Long) -> Unit = {}, private val onRemoveAccount: suspend (Long) -> Unit = {}, ) : ComponentContext by componentContext { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private var pendingAdditionalAuthorization: MonogramApp.PendingAccountAuthorization? = null + private var pendingAdditionalAuthorization: PendingAccountAuthorization? = null fun beginAddAccount() { scope.launch { @@ -131,8 +132,13 @@ class RootComponent( internal val proxySession: ProxySession = ClientProxySession(client) - fun openFromNotification(chatId: Long, messageId: Int = 0) { - if (stack.value.active.configuration.isAuthArea()) return + 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) { @@ -148,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, @@ -185,7 +196,11 @@ class RootComponent( onIncomingShareConsumed() } - private fun openChatFromList(peer: PeerId, forum: Boolean, messageId: Int = 0) { + private fun openChatFromList( + peer: PeerId, + forum: Boolean, + messageId: Int = 0, + ) { val destination = Config.Dialog( chatId = peer.value, isForum = forum, @@ -255,8 +270,11 @@ class RootComponent( 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, ) @@ -267,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 } @@ -276,18 +296,26 @@ 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.PrivateChannel -> { + openFromNotification(link.chatId, link.messageId) + } - is TelegramLink.Invite, is TelegramLink.Share -> AppLog.api("t.me", "link type ignored") + is TelegramLink.Invite, is TelegramLink.Share -> { + AppLog.api("t.me", "link type ignored") + } } } @@ -327,7 +355,7 @@ class RootComponent( scope.launch { sessionStore?.readAuthorizedUserId()?.value?.let { pushRegistration?.setAccountUserId( - it + it, ) } runCatching { pushRegistration?.reregister() } @@ -353,19 +381,29 @@ class RootComponent( 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) { @@ -375,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 @@ -464,19 +509,25 @@ class RootComponent( sessionStore?.upsertProfile(profile.value) if (expiringSession || version != accountFlagsVersion) return recoveredProfileSession( - profile.value.id, activeAccountUserId, activeAccountDcId + 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 + clearDialogMemory(client) + stopAudioMessagePlayback() onAccountWorkStop() stopLiveLocations() accountState.isPremium = false @@ -491,88 +542,101 @@ class RootComponent( } } - 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() - }, - ) + 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(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 } + 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") } - } 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() } } - if (expiringSession) return@launch - navigation.replaceAll(Config.Home) - pendingIncomingShare?.let(::openIncomingShare) - startLiveLocations() - pushRegistration?.setAccountUserId(session.userId.value) - refreshAccountFlags() - runCatching { pushRegistration?.reregister() } - } - }, - ) + }, + ) + } - 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) - } - }, - ) + 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) + } + }, + ) + } - Config.Proxy -> Child.Proxy + 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, - ), - ) + 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() @@ -593,19 +657,32 @@ class RootComponent( onOpenScheduledQueue = { navigation.navigate { uniqueStack( - it, Config.ScheduledQueue(config.chatId) + it, + Config.ScheduledQueue(config.chatId), ) } }, onResumeLiveLocation = liveLocations?.takeIf { liveDialogEpoch != null } - ?.let { runtime -> { id -> runtime.resume(id, liveDialogEpoch) } }, + ?.let { runtime -> + { id -> + runtime.resume( + id, liveDialogEpoch + ) + } + }, onStopLiveLocation = liveLocations?.takeIf { liveDialogEpoch != null } - ?.let { runtime -> { id -> runtime.requestStop(id, liveDialogEpoch) } }, + ?.let { runtime -> + { id -> + runtime.requestStop( + id, liveDialogEpoch + ) + } + }, liveLocationErrors = liveLocations?.errors ?: kotlinx.coroutines.flow.flowOf( - emptyMap() + emptyMap(), ), liveLocationFailure = liveLocations?.failure ?: kotlinx.coroutines.flow.flowOf( - null + null, ), showBackButton = config.chatId != bubbleChatId || config.threadTopMsgId != 0, onOpenProfile = ::openProfile, @@ -628,7 +705,7 @@ class RootComponent( fromChatId = config.chatId, messageIds = messages.map { it.id.id }, requiresPhotos = messages.any { it.requiresForwardPhotoRight() }, - ) + ), ) }, ) @@ -640,92 +717,119 @@ class RootComponent( Child.Dialog(component) } - is Config.ScheduledQueue -> Child.ScheduledQueue( - ScheduledQueueComponent( - context, - storeFactory, - client, - warmup, - PeerId(config.chatId), - ::onBack, - mediaRepository, - ), - ) + 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.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 - onAccountWorkStop() - stopLiveLocations() - }, - onLoggedOut = { - expiringSession = true - scope.launch { - onAccountLoggedOut() + 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() - 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, - ), - ) + }, + 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, @@ -735,7 +839,9 @@ 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 @@ -760,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( @@ -775,7 +885,9 @@ class RootComponent( } internal fun recoveredProfileSession( - profileUserId: PeerId, accountUserId: Long?, accountDcId: Int? + profileUserId: PeerId, + accountUserId: Long?, + accountDcId: Int?, ): AuthSession? = if (profileUserId.value > 0 && profileUserId.value == accountUserId && accountDcId != null && accountDcId > 0) { AuthSession(profileUserId, accountDcId) @@ -798,20 +910,30 @@ 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() + 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 } - RootComponent.Config.Proxy -> RootComponent.Config.Proxy - else -> RootComponent.Config.Home + else -> { + RootComponent.Config.Home + } } internal fun accountSwitchStack(destination: RootComponent.Config): List = - if (destination == RootComponent.Config.Home) listOf(destination) - else listOf(RootComponent.Config.Home, destination) + if (destination == RootComponent.Config.Home) { + listOf(destination) + } else { + listOf(RootComponent.Config.Home, destination) + } internal fun chatSelectionStack( configurations: List, @@ -831,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 } diff --git a/app/src/main/java/org/monogram/root/RootContent.kt b/app/src/main/java/org/monogram/root/RootContent.kt index 65fba0a0c..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,7 +127,9 @@ 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") val currentStack by component.stack.subscribeAsState() @@ -154,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() @@ -180,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) } @@ -200,7 +209,8 @@ private fun RootPlaybackContent( Direction.EXIT_BACK -> width * factor } alpha = alphaProgress - }) + }, + ) } predictiveBackAnimation( backHandler = backHandler, @@ -266,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) } @@ -289,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), @@ -310,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( @@ -325,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 -> @@ -335,7 +354,7 @@ private fun RootPlaybackContent( dragWidthDp = (dragWidthDp + (delta / density.density).roundToInt()).coerceIn( AppearanceSettings.MIN_LIST_PANE_WIDTH, - maxListWidthDp + maxListWidthDp, ) }, ) @@ -356,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 + }, ), ) } @@ -373,7 +395,7 @@ private fun RootPlaybackContent( topEnd = 32.dp, bottomStart = 32.dp, bottomEnd = 32.dp, - ) + ), ) } else { Modifier @@ -398,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) { @@ -516,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 = @@ -534,7 +584,7 @@ private fun RootPlaybackContent( (constraints.maxWidth - detailLeft).coerceAtLeast(0), 0, (constraints.maxHeight - detailTop).coerceAtLeast(0), - ) + ), ) if (tabletopListHeightDp != null) { val y = detailTop @@ -577,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), @@ -594,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 @@ -678,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/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/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/main/java/org/monogram/core/database/DatabaseProvider.kt b/core/database/src/main/java/org/monogram/core/database/DatabaseProvider.kt index 4bda1d88c..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 @@ -23,7 +23,7 @@ object DatabaseProvider { private val instances = mutableMapOf() - const val SCHEMA_VERSION = 10 + const val SCHEMA_VERSION = 11 val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { @@ -82,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( @@ -335,15 +342,16 @@ object DatabaseProvider { 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_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() { 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 f1c13f571..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 @@ -21,207 +21,203 @@ import org.monogram.core.models.TextEntities import org.monogram.core.models.isForcedVerifiedChat import org.monogram.core.models.preferredPeerTitle -fun ChatEntity.toModel(): Chat = - Chat( - id = PeerId(id), - title = title, - isChannel = isChannel, - isGroup = isGroup, - isForum = isForum, - left = left, - unreadCount = unreadCount, - lastMessagePreview = lastMessagePreview, - lastMessageOutgoing = lastMessageOutgoing, - lastMessageSenderName = lastMessageSenderName, - lastMessageMediaKind = lastMessageMediaKind, - lastMessageDate = lastMessageDate, - photoCacheKey = photoCacheKey, - archived = archived, - muted = muted, - muteOverride = muteOverride, - unreadMark = unreadMark, - unreadMentionsCount = unreadMentionsCount, - unreadReactionsCount = unreadReactionsCount, - isContact = isContact, - isBot = isBot, - isVerified = isVerified || isForcedVerifiedChat(id), - pinned = pinned, - pinnedOrder = pinnedOrder, - readInboxMaxId = readInboxMaxId, - readOutboxMaxId = readOutboxMaxId, - peerStatus = peerStatus, - peerStatusAt = peerStatusAt, - lastMediaThumbCacheKey = lastMediaThumbCacheKey, - lastMessageId = lastMessageId, - dialogScrollMessageId = dialogScrollMessageId, - canView = canView, - canSendPlain = canSendPlain, - canSendPhotos = canSendPhotos, - canForward = canForward, - canDeleteOthers = canDeleteOthers, - canManageTopics = canManageTopics, - emojiStatusDocumentId = emojiStatusDocumentId, - canSendPolls = canSendPolls, - ) +fun ChatEntity.toModel(): Chat = Chat( + id = PeerId(id), + title = title, + isChannel = isChannel, + isGroup = isGroup, + isForum = isForum, + left = left, + unreadCount = unreadCount, + lastMessagePreview = lastMessagePreview, + lastMessageOutgoing = lastMessageOutgoing, + lastMessageSenderName = lastMessageSenderName, + lastMessageMediaKind = lastMessageMediaKind, + lastMessageDate = lastMessageDate, + photoCacheKey = photoCacheKey, + photoKnown = photoKnown, + archived = archived, + muted = muted, + muteOverride = muteOverride, + unreadMark = unreadMark, + unreadMentionsCount = unreadMentionsCount, + unreadReactionsCount = unreadReactionsCount, + isContact = isContact, + isBot = isBot, + isVerified = isVerified || isForcedVerifiedChat(id), + pinned = pinned, + pinnedOrder = pinnedOrder, + readInboxMaxId = readInboxMaxId, + readOutboxMaxId = readOutboxMaxId, + peerStatus = peerStatus, + peerStatusAt = peerStatusAt, + lastMediaThumbCacheKey = lastMediaThumbCacheKey, + lastMessageId = lastMessageId, + dialogScrollMessageId = dialogScrollMessageId, + canView = canView, + canSendPlain = canSendPlain, + canSendPhotos = canSendPhotos, + canForward = canForward, + canDeleteOthers = canDeleteOthers, + canManageTopics = canManageTopics, + emojiStatusDocumentId = emojiStatusDocumentId, + canSendPolls = canSendPolls, +) -fun Chat.toEntity(): ChatEntity = - ChatEntity( - id = id.value, - title = title, - isChannel = isChannel, - isGroup = isGroup, - isForum = isForum, - left = left, - unreadCount = unreadCount, - lastMessagePreview = lastMessagePreview, - lastMessageOutgoing = lastMessageOutgoing, - lastMessageSenderName = lastMessageSenderName, - lastMessageMediaKind = lastMessageMediaKind, - lastMessageDate = lastMessageDate, - photoCacheKey = photoCacheKey, - archived = archived, - muted = muted, - muteOverride = muteOverride, - unreadMark = unreadMark, - unreadMentionsCount = unreadMentionsCount, - unreadReactionsCount = unreadReactionsCount, - isContact = isContact, - isBot = isBot, - isVerified = isVerified, - pinned = pinned, - pinnedOrder = pinnedOrder, - readInboxMaxId = readInboxMaxId, - readOutboxMaxId = readOutboxMaxId, - peerStatus = peerStatus, - peerStatusAt = peerStatusAt, - lastMediaThumbCacheKey = lastMediaThumbCacheKey, - lastMessageId = lastMessageId, - dialogScrollMessageId = dialogScrollMessageId, - canView = canView, - canSendPlain = canSendPlain, - canSendPhotos = canSendPhotos, - canForward = canForward, - canDeleteOthers = canDeleteOthers, - canManageTopics = canManageTopics, - emojiStatusDocumentId = emojiStatusDocumentId, - canSendPolls = canSendPolls, - ) +fun Chat.toEntity(): ChatEntity = ChatEntity( + id = id.value, + title = title, + isChannel = isChannel, + isGroup = isGroup, + isForum = isForum, + left = left, + unreadCount = unreadCount, + lastMessagePreview = lastMessagePreview, + lastMessageOutgoing = lastMessageOutgoing, + lastMessageSenderName = lastMessageSenderName, + lastMessageMediaKind = lastMessageMediaKind, + lastMessageDate = lastMessageDate, + photoCacheKey = photoCacheKey, + photoKnown = photoKnown, + archived = archived, + muted = muted, + muteOverride = muteOverride, + unreadMark = unreadMark, + unreadMentionsCount = unreadMentionsCount, + unreadReactionsCount = unreadReactionsCount, + isContact = isContact, + isBot = isBot, + isVerified = isVerified, + pinned = pinned, + pinnedOrder = pinnedOrder, + readInboxMaxId = readInboxMaxId, + readOutboxMaxId = readOutboxMaxId, + peerStatus = peerStatus, + peerStatusAt = peerStatusAt, + lastMediaThumbCacheKey = lastMediaThumbCacheKey, + lastMessageId = lastMessageId, + dialogScrollMessageId = dialogScrollMessageId, + canView = canView, + canSendPlain = canSendPlain, + canSendPhotos = canSendPhotos, + canForward = canForward, + canDeleteOthers = canDeleteOthers, + canManageTopics = canManageTopics, + emojiStatusDocumentId = emojiStatusDocumentId, + canSendPolls = canSendPolls, +) -fun FolderEntity.toModel(): Folder = - Folder( - id = id, - title = title, - emoticon = emoticon, - chatIds = parsePeerIds(chatIdsCsv), - pinnedChatIds = parsePeerIds(pinnedChatIdsCsv), - includeContacts = includeContacts, - includeNonContacts = includeNonContacts, - includeGroups = includeGroups, - includeChannels = includeChannels, - includeBots = includeBots, - excludeChatIds = parsePeerIds(excludeChatIdsCsv), - excludeMuted = excludeMuted, - excludeRead = excludeRead, - excludeArchived = excludeArchived, - ) +fun FolderEntity.toModel(): Folder = Folder( + id = id, + title = title, + emoticon = emoticon, + chatIds = parsePeerIds(chatIdsCsv), + pinnedChatIds = parsePeerIds(pinnedChatIdsCsv), + includeContacts = includeContacts, + includeNonContacts = includeNonContacts, + includeGroups = includeGroups, + includeChannels = includeChannels, + includeBots = includeBots, + excludeChatIds = parsePeerIds(excludeChatIdsCsv), + excludeMuted = excludeMuted, + excludeRead = excludeRead, + excludeArchived = excludeArchived, +) -fun Folder.toEntity(position: Int = 0): FolderEntity = - FolderEntity( - id = id, - title = title, - emoticon = emoticon, - chatIdsCsv = peerIdsCsv(chatIds), - pinnedChatIdsCsv = peerIdsCsv(pinnedChatIds), - includeContacts = includeContacts, - includeNonContacts = includeNonContacts, - includeGroups = includeGroups, - includeChannels = includeChannels, - includeBots = includeBots, - excludeChatIdsCsv = peerIdsCsv(excludeChatIds), - excludeMuted = excludeMuted, - excludeRead = excludeRead, - excludeArchived = excludeArchived, - position = position, - ) +fun Folder.toEntity(position: Int = 0): FolderEntity = FolderEntity( + id = id, + title = title, + emoticon = emoticon, + chatIdsCsv = peerIdsCsv(chatIds), + pinnedChatIdsCsv = peerIdsCsv(pinnedChatIds), + includeContacts = includeContacts, + includeNonContacts = includeNonContacts, + includeGroups = includeGroups, + includeChannels = includeChannels, + includeBots = includeBots, + excludeChatIdsCsv = peerIdsCsv(excludeChatIds), + excludeMuted = excludeMuted, + excludeRead = excludeRead, + excludeArchived = excludeArchived, + position = position, +) -fun MessageEntity.toModel(): Message = - Message( - id = MessageId(chatId = PeerId(chatId), id = id), - senderId = senderId?.let(::PeerId), - text = text, - date = date, - editDate = editDate, - outgoing = outgoing, - mediaCacheKey = mediaCacheKey, - mediaKind = mediaKind, - thumbCacheKey = thumbCacheKey, - mediaDuration = mediaDuration, - mediaWidth = mediaWidth, - mediaHeight = mediaHeight, - groupedId = groupedId, - fileName = fileName, - fileSize = fileSize, - supportsStreaming = supportsStreaming, - reactionsJson = reactionsJson, - repliesCount = repliesCount, - discussionPeerId = discussionPeerId, - replyQuote = replyQuote, - replyQuoteEntities = TextEntities.parse(replyQuoteEntitiesJson), - entities = TextEntities.parse(entitiesJson), - replyToMsgId = replyToMsgId, - replyToTopId = replyToTopId, - forumTopic = forumTopic, - fwdFrom = fwdFrom, - fwdFromId = fwdFromId, - fwdDate = fwdDate, - viaBot = viaBot, - senderName = senderName, - replyMarkup = ReplyMarkups.parse(replyMarkupJson), - pending = pending, - failed = failed, - randomId = randomId, - checklist = Checklists.parse(checklistJson), - ) +fun MessageEntity.toModel(): Message = Message( + id = MessageId(chatId = PeerId(chatId), id = id), + senderId = senderId?.let(::PeerId), + text = text, + date = date, + editDate = editDate, + outgoing = outgoing, + mediaCacheKey = mediaCacheKey, + mediaKind = mediaKind, + thumbCacheKey = thumbCacheKey, + mediaDuration = mediaDuration, + mediaWidth = mediaWidth, + mediaHeight = mediaHeight, + groupedId = groupedId, + fileName = fileName, + fileSize = fileSize, + supportsStreaming = supportsStreaming, + reactionsJson = reactionsJson, + repliesCount = repliesCount, + discussionPeerId = discussionPeerId, + replyQuote = replyQuote, + replyQuoteEntities = TextEntities.parse(replyQuoteEntitiesJson), + entities = TextEntities.parse(entitiesJson), + replyToMsgId = replyToMsgId, + replyToTopId = replyToTopId, + forumTopic = forumTopic, + fwdFrom = fwdFrom, + fwdFromId = fwdFromId, + fwdDate = fwdDate, + viaBot = viaBot, + senderName = senderName, + replyMarkup = ReplyMarkups.parse(replyMarkupJson), + pending = pending, + failed = failed, + randomId = randomId, + checklist = Checklists.parse(checklistJson), +) -fun Message.toEntity(): MessageEntity = - MessageEntity( - chatId = id.chatId.value, - id = id.id, - senderId = senderId?.value, - text = text, - date = date, - editDate = editDate, - outgoing = outgoing, - mediaCacheKey = mediaCacheKey, - mediaKind = mediaKind, - thumbCacheKey = thumbCacheKey, - mediaDuration = mediaDuration, - mediaWidth = mediaWidth, - mediaHeight = mediaHeight, - groupedId = groupedId, - fileName = fileName, - fileSize = fileSize, - supportsStreaming = supportsStreaming, - reactionsJson = reactionsJson, - repliesCount = repliesCount, - discussionPeerId = discussionPeerId, - replyQuote = replyQuote, - replyQuoteEntitiesJson = TextEntities.serialize(replyQuoteEntities), - entitiesJson = TextEntities.serialize(entities), - replyToMsgId = replyToMsgId, - replyToTopId = replyToTopId, - forumTopic = forumTopic, - fwdFrom = fwdFrom, - fwdFromId = fwdFromId, - fwdDate = fwdDate, - viaBot = viaBot, - senderName = senderName, - replyMarkupJson = ReplyMarkups.serialize(replyMarkup), - pending = pending, - failed = failed, - randomId = randomId, - checklistJson = Checklists.serialize(checklist), - ) +fun Message.toEntity(): MessageEntity = MessageEntity( + chatId = id.chatId.value, + id = id.id, + senderId = senderId?.value, + text = text, + date = date, + editDate = editDate, + outgoing = outgoing, + mediaCacheKey = mediaCacheKey, + mediaKind = mediaKind, + thumbCacheKey = thumbCacheKey, + mediaDuration = mediaDuration, + mediaWidth = mediaWidth, + mediaHeight = mediaHeight, + groupedId = groupedId, + fileName = fileName, + fileSize = fileSize, + supportsStreaming = supportsStreaming, + reactionsJson = reactionsJson, + repliesCount = repliesCount, + discussionPeerId = discussionPeerId, + replyQuote = replyQuote, + replyQuoteEntitiesJson = TextEntities.serialize(replyQuoteEntities), + entitiesJson = TextEntities.serialize(entities), + replyToMsgId = replyToMsgId, + replyToTopId = replyToTopId, + forumTopic = forumTopic, + fwdFrom = fwdFrom, + fwdFromId = fwdFromId, + fwdDate = fwdDate, + viaBot = viaBot, + senderName = senderName, + replyMarkupJson = ReplyMarkups.serialize(replyMarkup), + pending = pending, + failed = failed, + randomId = randomId, + checklistJson = Checklists.serialize(checklist), +) fun PeerEntity.toProfile(): Profile { val extra = ProfileExtras.parse(extraJson) @@ -233,6 +229,7 @@ fun PeerEntity.toProfile(): Profile { username = username, about = about, avatarCacheKey = avatarCacheKey, + avatarKnown = avatarKnown, isSelf = isSelf, status = status, statusAt = statusAt, @@ -243,28 +240,26 @@ fun PeerEntity.toProfile(): Profile { } fun Profile.toPeerEntity(prior: PeerEntity?): PeerEntity { - val extra = - ProfileExtras.mergeFull( - ProfileExtras.parse(prior?.extraJson), - ProfileExtras.fromProfile(this), - ) - val kind = - when { - this.kind.isNotBlank() && - (prior?.kind == "channel" || prior?.kind == "group" || prior?.kind == "chat") && - this.kind == "user" -> prior.kind + val extra = ProfileExtras.mergeFull( + ProfileExtras.parse(prior?.extraJson), + 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() -> this.kind + this.kind.isNotBlank() -> this.kind - else -> prior?.kind ?: "user" - } + else -> prior?.kind ?: "user" + } return PeerEntity( id = id.value, kind = kind, 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, @@ -277,98 +272,87 @@ 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, - ) +): 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), - title = title, - username = username, - avatarCacheKey = avatarCacheKey, - status = status, - statusAt = statusAt, - role = role, - rank = rank, - ) +fun ProfileMemberEntity.toModel(): ProfileMember = ProfileMember( + id = PeerId(userId), + title = title, + username = username, + avatarCacheKey = avatarCacheKey, + status = status, + statusAt = statusAt, + role = role, + 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, - ) +): 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), - senderId = null, - 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), + senderId = null, + text = text, + date = date, + outgoing = outgoing, + mediaCacheKey = mediaCacheKey, + mediaKind = mediaKind, + thumbCacheKey = thumbCacheKey, + fileName = fileName, + 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, - ) +): 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), - title = title, - isChannel = isChannel, - isGroup = isGroup, - photoCacheKey = photoCacheKey, - ) +fun ProfileCommonEntity.toChat(): Chat = Chat( + id = PeerId(chatId), + title = title, + isChannel = isChannel, + isGroup = isGroup, + 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() } 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 a54e51db9..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 @@ -65,9 +65,7 @@ open class OfflineWarmup( val outgoingSendSync = OutgoingSendSyncMonitor() private val activeContactImports = mutableMapOf, Long>() private val draftWriteMutex = Mutex() - private val outgoingAccountEpoch = - java.util.concurrent.atomic - .AtomicLong() + private val outgoingAccountEpoch = java.util.concurrent.atomic.AtomicLong() @Volatile private var outgoingAccountActive = true @@ -77,18 +75,16 @@ open class OfflineWarmup( fun outgoingAccountIsCurrent(epoch: Long): Boolean = outgoingAccountActive && outgoingAccountEpoch.get() == epoch - suspend fun deactivateOutgoingAccount() = - draftWriteMutex.withLock { - outgoingAccountActive = false - outgoingAccountEpoch.incrementAndGet() - Unit - } + suspend fun deactivateOutgoingAccount() = draftWriteMutex.withLock { + outgoingAccountActive = false + outgoingAccountEpoch.incrementAndGet() + Unit + } - suspend fun activateOutgoingAccount() = - draftWriteMutex.withLock { - if (!outgoingAccountActive) outgoingAccountEpoch.incrementAndGet() - outgoingAccountActive = true - } + suspend fun activateOutgoingAccount() = draftWriteMutex.withLock { + if (!outgoingAccountActive) outgoingAccountEpoch.incrementAndGet() + outgoingAccountActive = true + } @Volatile private var firstPaintSnapshot: OfflineWarmupSnapshot? = null @@ -101,23 +97,21 @@ open class OfflineWarmup( fun startupSnapshot(): OfflineWarmupSnapshot? = firstPaintSnapshot - fun takeStartupSnapshot(): OfflineWarmupSnapshot? = - synchronized(this) { - firstPaintSnapshot.also { firstPaintSnapshot = null } - } + fun takeStartupSnapshot(): OfflineWarmupSnapshot? = synchronized(this) { + firstPaintSnapshot.also { firstPaintSnapshot = null } + } suspend fun prepareStartupSnapshot(sessionStore: SessionMetadataStore) { ensureStartupCleanup() val started = PerfLog.nowMs() val chats = chatsWindow(40) val selfId = sessionStore.readAuthorizedUserId() - val snapshot = - OfflineWarmupSnapshot( - chats = chats, - folders = folders(), - self = selfId?.let { sessionStore.readProfile(it.value) }, - mainCount = mainListCount(), - ) + val snapshot = OfflineWarmupSnapshot( + chats = chats, + folders = folders(), + self = selfId?.let { sessionStore.readProfile(it.value) }, + mainCount = mainListCount(), + ) synchronized(this) { firstPaintSnapshot = snapshot } recentWarmupChats = chats PerfLog.mark("cache:startupSnapshot", PerfLog.nowMs() - started, "count=${chats.size}") @@ -146,25 +140,20 @@ open class OfflineWarmup( open suspend fun chatsAfter( cursor: ChatCacheCursor, limit: Int, - ): List = - roomIo { - db - ?.chatDao() - ?.afterCursor( - cursor.archived, - cursor.pinned, - cursor.pinnedOrder, - cursor.lastMessageDate, - cursor.id, - limit, - )?.map { it.toModel() } - .orEmpty() - } - - open suspend fun oldestListed(archived: Boolean): Chat? = - roomIo { - db?.chatDao()?.oldestListed(archived)?.toModel() - } + ): List = roomIo { + db?.chatDao()?.afterCursor( + cursor.archived, + cursor.pinned, + cursor.pinnedOrder, + cursor.lastMessageDate, + cursor.id, + limit, + )?.map { it.toModel() }.orEmpty() + } + + open suspend fun oldestListed(archived: Boolean): Chat? = roomIo { + db?.chatDao()?.oldestListed(archived)?.toModel() + } /** Callers use this to skip redundant dispatcher hops for in-memory cache adapters. */ open val usesIoDispatcher: Boolean get() = db != null @@ -220,18 +209,11 @@ open class OfflineWarmup( 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 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() + val kept = (newest + database.messageDao() + .stickyIds(chatId, cutoffSeconds, 200)).distinct() kept.minOrNull()?.let { oldestKept -> database.messageTombstoneDao().deleteBelow(chatId, oldestKept) } @@ -315,59 +297,44 @@ open class OfflineWarmup( open suspend fun cloudDraftState( chatId: PeerId, threadId: Int = 0, - ): CloudDraftState = + ): CloudDraftState = roomIo { + db?.let { readCloudDraftState(it, chatId, threadId) } ?: CloudDraftState( + CloudDraft( + chatId, + threadId, + ), + ) + } + + open fun observeCloudDraft( + chatId: PeerId, + threadId: Int = 0, + ): Flow = db?.metaDao()?.observeDraft( + cloudDraftMetaKey(chatId.value, threadId), + draftMetaKey(chatId.value, threadId), + )?.distinctUntilChanged()?.map { rows -> roomIo { - db?.let { readCloudDraftState(it, chatId, threadId) } ?: CloudDraftState( + cloudDraftStates(rows).firstOrNull() ?: CloudDraftState( CloudDraft( chatId, threadId, ), ) } - - 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() + }?.distinctUntilChanged() ?: emptyFlow() open fun observeCloudDraftStates(): Flow> = - db - ?.metaDao() - ?.observeDrafts() - ?.distinctUntilChanged() - ?.map { rows -> roomIo { cloudDraftStates(rows) } } - ?.distinctUntilChanged() ?: emptyFlow() - - open suspend fun cloudDraftStates(): List = - roomIo { - db - ?.metaDao() - ?.drafts() - ?.let(::cloudDraftStates) - .orEmpty() - } + db?.metaDao()?.observeDrafts()?.distinctUntilChanged() + ?.map { rows -> roomIo { cloudDraftStates(rows) } }?.distinctUntilChanged() + ?: emptyFlow() + + open suspend fun cloudDraftStates(): List = roomIo { + 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() + return rows.mapNotNull { draftContextFromMetaKey(it.key) }.distinct() .map { (chatId, threadId) -> val modern = stored[cloudDraftMetaKey(chatId.value, threadId)] if (modern != null) { @@ -402,13 +369,12 @@ open class OfflineWarmup( open suspend fun discardLocalCloudDraft( chatId: PeerId, threadId: Int = 0, - ): CloudDraftState = - editLocalCloudDraft(chatId, threadId, discardUnsupported = true) { - CloudDraft( - chatId, - threadId, - ) - } + ): CloudDraftState = editLocalCloudDraft(chatId, threadId, discardUnsupported = true) { + CloudDraft( + chatId, + threadId, + ) + } private suspend fun editLocalCloudDraft( chatId: PeerId, @@ -416,26 +382,25 @@ open class OfflineWarmup( 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, - ) - } - database.withTransaction { - val current = readCloudDraftState(database, chatId, threadId) - val state = current.localEdit(edit(current.draft), discardUnsupported, markdown) - writeCloudDraftState(database, state) - state - } + ): CloudDraftState = draftWriteMutex.withLock { + roomIo { + val database = db + if (database == null) { + val initial = CloudDraft(chatId, threadId) + return@roomIo CloudDraftState(initial).localEdit( + edit(initial), + discardUnsupported, + markdown, + ) + } + database.withTransaction { + val current = readCloudDraftState(database, chatId, threadId) + val state = current.localEdit(edit(current.draft), discardUnsupported, markdown) + writeCloudDraftState(database, state) + state } } + } open suspend fun applyCloudDraft( draft: CloudDraft, @@ -500,14 +465,9 @@ open class OfflineWarmup( open suspend fun discussionReadMax( chatId: PeerId, topId: Int, - ): Int = - roomIo { - db - ?.metaDao() - ?.get("discussion_read:${chatId.value}:$topId") - ?.value - ?.toIntOrNull() ?: 0 - } + ): Int = roomIo { + db?.metaDao()?.get("discussion_read:${chatId.value}:$topId")?.value?.toIntOrNull() ?: 0 + } open suspend fun applyDiscussionRead( chatId: PeerId, @@ -516,15 +476,11 @@ open class OfflineWarmup( ) = roomIo { val database = db ?: return@roomIo database.withTransaction { - val confirmed = - maxOf( - database - .metaDao() - .get("discussion_read:${chatId.value}:$topId") - ?.value - ?.toIntOrNull() ?: 0, - maxId, - ) + val confirmed = maxOf( + database.metaDao() + .get("discussion_read:${chatId.value}:$topId")?.value?.toIntOrNull() ?: 0, + maxId, + ) database.metaDao().upsert( MetaEntity( "discussion_read:${chatId.value}:$topId", @@ -579,16 +535,15 @@ open class OfflineWarmup( db?.chatDao()?.addUnreadReactions(chatId.value, delta) } - open suspend fun applyMessageEdit(message: Message) = - roomIo { - recentHistory.invalidate(message.id.chatId.value) - val database = db ?: return@roomIo - database.withTransaction { - if (mergeMessages(listOf(message)).accepted.isNotEmpty()) { - refreshPreview(message.id.chatId, message.id.id) - } + open suspend fun applyMessageEdit(message: Message) = roomIo { + recentHistory.invalidate(message.id.chatId.value) + val database = db ?: return@roomIo + database.withTransaction { + if (mergeMessages(listOf(message)).accepted.isNotEmpty()) { + refreshPreview(message.id.chatId, message.id.id) } } + } private suspend fun refreshPreview( chatId: PeerId, @@ -603,34 +558,27 @@ open class OfflineWarmup( private suspend fun refreshLatestPreview(chatId: PeerId) { val database = db ?: return val chat = database.chatDao().get(chatId.value)?.toModel() ?: return - val latest = - database - .messageDao() - .latestForChat(chatId.value, 1) - .firstOrNull() - ?.toModel() + val latest = database.messageDao().latestForChat(chatId.value, 1).firstOrNull()?.toModel() database.chatDao().upsertAll( listOf( - chat - .copy( - lastMessageId = latest?.id?.id ?: 0, - lastMessagePreview = latest?.chatListPreviewSource(), - lastMessageDate = latest?.date, - lastMessageOutgoing = latest?.outgoing ?: false, - lastMessageSenderName = latest?.senderName, - lastMessageMediaKind = latest?.mediaKind, - lastMediaThumbCacheKey = latest?.thumbCacheKey, - ).toEntity(), + chat.copy( + lastMessageId = latest?.id?.id ?: 0, + lastMessagePreview = latest?.chatListPreviewSource(), + lastMessageDate = latest?.date, + lastMessageOutgoing = latest?.outgoing ?: false, + lastMessageSenderName = latest?.senderName, + lastMessageMediaKind = latest?.mediaKind, + lastMediaThumbCacheKey = latest?.thumbCacheKey, + ).toEntity(), ), ) } open suspend fun chats(): List { - val database = - db ?: run { - ensureStartupCleanup() - return emptyList() - } + val database = db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() database.chatDao().observeAll().map { it.toModel() } @@ -641,11 +589,10 @@ open class OfflineWarmup( limit: Int, archiveLimit: Int = 3, ): List { - val database = - db ?: run { - ensureStartupCleanup() - return chats() - } + val database = db ?: run { + ensureStartupCleanup() + return chats() + } return DatabaseProvider.onRoom { ensureStartupCleanup() val main = database.chatDao().mainListPage(limit, 0) @@ -655,11 +602,10 @@ open class OfflineWarmup( } open suspend fun mainListCount(): Int { - val database = - db ?: run { - ensureStartupCleanup() - return 0 - } + val database = db ?: run { + ensureStartupCleanup() + return 0 + } return DatabaseProvider.onRoom { ensureStartupCleanup() database.chatDao().mainListCount() @@ -667,11 +613,10 @@ open class OfflineWarmup( } open suspend fun chat(chatId: PeerId): Chat? { - val database = - db ?: run { - ensureStartupCleanup() - return chats().firstOrNull { it.id == chatId } - } + val database = db ?: run { + ensureStartupCleanup() + return chats().firstOrNull { it.id == chatId } + } return DatabaseProvider.onRoom { ensureStartupCleanup() database.chatDao().get(chatId.value)?.toModel() @@ -679,11 +624,10 @@ open class OfflineWarmup( } open suspend fun folders(): List { - val database = - db ?: run { - ensureStartupCleanup() - return emptyList() - } + val database = db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() database.folderDao().observeAll().map { it.toModel() } @@ -725,11 +669,8 @@ open class OfflineWarmup( return@roomIo } database.withTransaction { - val pending = - database - .messageDao() - .byIds(message.id.chatId.value, listOf(pendingId)) - .singleOrNull() + val pending = database.messageDao().byIds(message.id.chatId.value, listOf(pendingId)) + .singleOrNull() if (pendingId != message.id.id && randomId != null && randomId != 0L && pending?.randomId == randomId) { database.messageDao().delete(message.id.chatId.value, pendingId) } @@ -742,11 +683,10 @@ open class OfflineWarmup( chatId: PeerId, limit: Int = HISTORY_CACHE_WINDOW, ): List { - val database = - db ?: run { - ensureStartupCleanup() - return emptyList() - } + val database = db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() val pending = database.messageDao().pendingForChat(chatId.value).map { it.toModel() } @@ -761,11 +701,10 @@ open class OfflineWarmup( ids: List, ): List { if (ids.isEmpty()) return emptyList() - val database = - db ?: run { - ensureStartupCleanup() - return emptyList() - } + val database = db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() val holes = @@ -782,18 +721,14 @@ open class OfflineWarmup( beforeId: Int, limit: Int = HISTORY_CACHE_WINDOW, ): List { - val database = - db ?: run { - ensureStartupCleanup() - return emptyList() - } + val database = db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() val rows = database.messageDao().olderThan(chatId.value, beforeId, limit) - if (database - .metaDao() - .get("history_coverage:${chatId.value}") == null - ) { + if (database.metaDao().get("history_coverage:${chatId.value}") == null) { return@onRoom emptyList() } if (rows.isEmpty()) return@onRoom emptyList() @@ -815,18 +750,14 @@ open class OfflineWarmup( afterId: Int, limit: Int = HISTORY_CACHE_WINDOW, ): List { - val database = - db ?: run { - ensureStartupCleanup() - return emptyList() - } + val database = db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() val rows = database.messageDao().newerThan(chatId.value, afterId, limit) - if (database - .metaDao() - .get("history_coverage:${chatId.value}") == null - ) { + if (database.metaDao().get("history_coverage:${chatId.value}") == null) { return@onRoom emptyList() } if (rows.isEmpty()) return@onRoom emptyList() @@ -852,47 +783,38 @@ open class OfflineWarmup( val database = db ?: return HistorySlice(emptyList(), false, emptyList(), false, false, false) return DatabaseProvider.onRoom { - val (entities, holes, initialized) = - database.withTransaction { - val rows = - when { - beforeId != null -> { - database - .messageDao() - .olderThan(chatId.value, beforeId, limit) - } + 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) - } + 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, - ) + 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 - else -> rows.minOfOrNull { it.id.id } ?: 1 - } - val end = - when { - beforeId != null -> beforeId - 1 - afterId != null -> rows.maxOfOrNull { it.id.id } ?: afterId - else -> rows.maxOfOrNull { it.id.id } ?: 0 - } + val start = when { + beforeId != null -> (rows.minOfOrNull { it.id.id } ?: 1) + afterId != null -> afterId + 1 + else -> rows.minOfOrNull { it.id.id } ?: 1 + } + val end = when { + beforeId != null -> beforeId - 1 + afterId != null -> rows.maxOfOrNull { it.id.id } ?: afterId + else -> rows.maxOfOrNull { it.id.id } ?: 0 + } historySliceOf(rows, initialized, holes, start, end) } } @@ -901,12 +823,10 @@ open class OfflineWarmup( chatId: PeerId, startId: Int, endId: Int, - ): Boolean = - roomIo { - db - ?.messageHoleDao() - ?.overlaps(chatId.value, startId, endId, MessageHoleEntity.SOURCE_HISTORY) ?: false - } + ): Boolean = roomIo { + db?.messageHoleDao() + ?.overlaps(chatId.value, startId, endId, MessageHoleEntity.SOURCE_HISTORY) ?: false + } open suspend fun cacheHistoryPage( chatId: PeerId, @@ -988,41 +908,36 @@ open class OfflineWarmup( beforeDate: Int, limit: Int = HISTORY_CACHE_WINDOW, ): List { - val database = - db ?: run { - ensureStartupCleanup() - return emptyList() - } + val database = db ?: run { + ensureStartupCleanup() + return emptyList() + } return DatabaseProvider.onRoom { ensureStartupCleanup() - database - .messageDao() - .atOrBeforeDate(chatId.value, beforeDate.toLong(), limit) + database.messageDao().atOrBeforeDate(chatId.value, beforeDate.toLong(), limit) .map { it.toModel() } } } - 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 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 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) @@ -1030,18 +945,17 @@ open class OfflineWarmup( val stored = roomIo { db?.chatDao()?.get(profile.id.value)?.toModel() } ?: return val title = profile.title.takeIf { !isPlaceholderPeerTitle(it, profile.id.value) } ?: stored.title - val updated = - stored.copy( - title = title, - isChannel = profile.kind == "channel", - isGroup = profile.kind == "group" || profile.kind == "chat", - photoCacheKey = profile.avatarCacheKey ?: stored.photoCacheKey, - peerStatus = profile.status ?: stored.peerStatus, - peerStatusAt = profile.statusAt ?: stored.peerStatusAt, - emojiStatusDocumentId = - profile.emojiStatusDocumentId - ?: stored.emojiStatusDocumentId, - ) + val updated = stored.copy( + title = title, + isChannel = profile.kind == "channel", + isGroup = profile.kind == "group" || profile.kind == "chat", + 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, + ) upsertChats(listOf(updated)) } @@ -1052,28 +966,23 @@ open class OfflineWarmup( database.withTransaction { val stored = database.chatDao().getByIds(chats.map { it.id.value }).associateBy { it.id } - val changed = - chats - .map { chat -> - chat.mergeLocalCache(stored[chat.id.value]?.toModel()).toEntity() - }.associateBy { it.id } - .values - .filter { it != stored[it.id] } + 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) } } } - open suspend fun replaceFolders(folders: List) = - roomIo { - val database = db ?: return@roomIo - // List order is the folder order, so the index has to land in the row. - val rows = folders.mapIndexed { index, folder -> folder.toEntity(position = index) } - database.withTransaction { - database.folderDao().clear() - database.folderDao().upsertAll(rows) - } + open suspend fun replaceFolders(folders: List) = roomIo { + val database = db ?: return@roomIo + // List order is the folder order, so the index has to land in the row. + val rows = folders.mapIndexed { index, folder -> folder.toEntity(position = index) } + database.withTransaction { + database.folderDao().clear() + database.folderDao().upsertAll(rows) } + } open suspend fun replaceMessages( chatId: PeerId, @@ -1092,40 +1001,37 @@ open class OfflineWarmup( 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, - ), - ) - } - true + ): 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, + ), + ) } + true + } - open suspend fun upsertMessages(messages: List) = - withContext(RoomLane.maintenance()) { - upsertMessagesNow(messages) - } + open suspend fun upsertMessages(messages: List) = withContext(RoomLane.maintenance()) { + upsertMessagesNow(messages) + } - private suspend fun upsertMessagesNow(messages: List) = - roomIo { - messages.map { it.id.chatId.value }.distinct().forEach(recentHistory::invalidate) - if (messages.isEmpty()) return@roomIo - val database = db ?: return@roomIo - for (batch in chunkMessages(messages)) { - currentCoroutineContext().ensureActive() - database.withTransaction { mergeMessages(batch) } - kotlinx.coroutines.yield() - } - pruneExpiredCache() + private suspend fun upsertMessagesNow(messages: List) = roomIo { + messages.map { it.id.chatId.value }.distinct().forEach(recentHistory::invalidate) + if (messages.isEmpty()) return@roomIo + val database = db ?: return@roomIo + for (batch in chunkMessages(messages)) { + currentCoroutineContext().ensureActive() + database.withTransaction { mergeMessages(batch) } + kotlinx.coroutines.yield() } + pruneExpiredCache() + } private data class MergedMessages( val accepted: List, @@ -1141,14 +1047,9 @@ open class OfflineWarmup( val stored = mutableMapOf() for ((chatId, rows) in messages.groupBy { it.id.chatId.value }) { val ids = rows.map { it.id.id } - deleted += - database - .messageTombstoneDao() - .deletedIds(chatId, ids) - .map { MessageId(PeerId(chatId), it) } - database - .messageDao() - .byIds(chatId, ids) + 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) { @@ -1187,8 +1088,9 @@ open class OfflineWarmup( 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) + org.monogram.core.database.entity.MessageTombstoneEntity( + chatId?.value ?: 0L, it + ) }, ) if (chatId != null) { @@ -1210,13 +1112,12 @@ open class OfflineWarmup( if (dropped.isEmpty()) continue recentHistory.invalidate(chat.id.value) database.messageDao().deleteAfter(chat.id.value, chat.lastMessageId) - out += - HistoryPrune( - chatId = chat.id.value, - lastMessageId = chat.lastMessageId, - cachedMaxId = dropped.max(), - droppedIds = dropped, - ) + out += HistoryPrune( + chatId = chat.id.value, + lastMessageId = chat.lastMessageId, + cachedMaxId = dropped.max(), + droppedIds = dropped, + ) } out } @@ -1232,11 +1133,10 @@ open class OfflineWarmup( if (fetched.isEmpty()) return@roomIo val keep = fetched.map { it.id.id } val minId = keep.minOrNull() ?: return@roomIo - val stale = - staleIdsInsideFetchedWindow( - cachedIds = database.messageDao().idsAtOrAfter(chatId.value, minId), - fetchedIds = keep, - ) + val stale = staleIdsInsideFetchedWindow( + cachedIds = database.messageDao().idsAtOrAfter(chatId.value, minId), + fetchedIds = keep, + ) if (stale.isNotEmpty()) { database.messageDao().deleteInChat(chatId.value, stale) } @@ -1253,13 +1153,12 @@ open class OfflineWarmup( 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 = merged.alreadyStored.single(), - accountGeneration = accountGeneration, - acceptedOrder = acceptedOrder, - ) + val chat = (stored ?: placeholderChat(message.id.chatId)).mergeIncomingMessage( + message, + alreadyStored = merged.alreadyStored.single(), + accountGeneration = accountGeneration, + acceptedOrder = acceptedOrder, + ) if (chat != stored) database.chatDao().upsertAll(listOf(chat.toEntity())) } } @@ -1284,11 +1183,9 @@ open class OfflineWarmup( 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 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 -> @@ -1325,62 +1222,51 @@ open class OfflineWarmup( cursor: UpdateCursorEntity?, channelCursors: Map, apply: suspend () -> Unit, - ): Boolean = - roomIo { - val database = db ?: return@roomIo false - database.withTransaction { - val dao = database.updateCursorDao() - if (dao.isBatchApplied(accountGeneration, batchId)) return@withTransaction true - apply() - cursor?.let { dao.upsert(it) } - for ((chatId, pts) in channelCursors) { - val key = "$KEY_CHANNEL_CURSOR_PREFIX$chatId" - val stored = dao.get(key) - if (stored == null || stored.channelPts < pts) { - dao.upsert(UpdateCursorEntity(key, 0, 0, 0, 0, channelPts = pts)) - } + ): Boolean = roomIo { + val database = db ?: return@roomIo false + database.withTransaction { + val dao = database.updateCursorDao() + if (dao.isBatchApplied(accountGeneration, batchId)) return@withTransaction true + apply() + cursor?.let { dao.upsert(it) } + for ((chatId, pts) in channelCursors) { + val key = "$KEY_CHANNEL_CURSOR_PREFIX$chatId" + val stored = dao.get(key) + if (stored == null || stored.channelPts < pts) { + dao.upsert(UpdateCursorEntity(key, 0, 0, 0, 0, channelPts = pts)) } - dao.insertReceipt( - UpdateBatchReceiptEntity( - accountGeneration, - batchId, - System.currentTimeMillis(), - ), - ) - dao.pruneReceipts(accountGeneration, batchId - RECEIPT_WINDOW) - true } + dao.insertReceipt( + UpdateBatchReceiptEntity( + accountGeneration, + batchId, + System.currentTimeMillis(), + ), + ) + dao.pruneReceipts(accountGeneration, batchId - RECEIPT_WINDOW) + true } + } /** Drops cached chats/history/peers/folders. Auth flags are [SessionMetadataStore.clearSession]. */ - open suspend fun outgoingSendBlockedUntil(): Long = - roomIo { - db - ?.metaDao() - ?.get("outgoing_send.blocked_until_ms") - ?.value - ?.toLongOrNull() ?: 0L - } + open suspend fun outgoingSendBlockedUntil(): Long = roomIo { + db?.metaDao()?.get("outgoing_send.blocked_until_ms")?.value?.toLongOrNull() ?: 0L + } - open suspend fun blockOutgoingSendsUntil(deadline: Long) = - roomIo { - val database = db ?: return@roomIo - database.withTransaction { - val old = - database - .metaDao() - .get("outgoing_send.blocked_until_ms") - ?.value - ?.toLongOrNull() - ?: 0L - database.metaDao().upsert( - MetaEntity( - "outgoing_send.blocked_until_ms", - maxOf(old, deadline).toString(), - ), - ) - } + open suspend fun blockOutgoingSendsUntil(deadline: Long) = roomIo { + val database = db ?: return@roomIo + database.withTransaction { + val old = + database.metaDao().get("outgoing_send.blocked_until_ms")?.value?.toLongOrNull() + ?: 0L + database.metaDao().upsert( + MetaEntity( + "outgoing_send.blocked_until_ms", + maxOf(old, deadline).toString(), + ), + ) } + } open fun observeOutgoingTextSends(): Flow> = db?.outgoingTextSendDao()?.observeAll() ?: flowOf(emptyList()) @@ -1396,159 +1282,131 @@ open class OfflineWarmup( chatId: PeerId, threadId: Int, expectedEpoch: Long, - ): CloudDraftState = - draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - cloudDraftState(chatId, threadId) - } + ): CloudDraftState = draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + cloudDraftState(chatId, threadId) + } /** Persists the send; returns whether its matching draft was consumed in the same transaction. */ open suspend fun contactImportAttempt( 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 - } + ): 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 } + } open suspend fun beginContactImport( spec: ContactSendSpec, epoch: Long, retry: Boolean, - ): ContactImportAttempt = - draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(epoch)) - spec.requireValid() - val key = contactImportMetaKey(spec) - check((epoch to key) !in activeContactImports) - val attempt = - roomIo { - val database = checkNotNull(db) - database.withTransaction { - val previous = - database - .metaDao() - .get(key) - ?.value - ?.let(::decodeContactImport) - check( - previous == null || retry && previous.phase in - setOf( - ContactImportPhase.UNCERTAIN, - ContactImportPhase.RETRY_REQUIRED, - ContactImportPhase.NOT_ADDED, - ContactImportPhase.IN_FLIGHT, - ), - ) - 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, - ).also { - database.metaDao().upsert(MetaEntity(key, encodeContactImport(it))) - } - } + ): ContactImportAttempt = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + spec.requireValid() + val key = contactImportMetaKey(spec) + check((epoch to key) !in activeContactImports) + val attempt = roomIo { + val database = checkNotNull(db) + database.withTransaction { + val previous = database.metaDao().get(key)?.value?.let(::decodeContactImport) + check( + previous == null || retry && previous.phase in setOf( + ContactImportPhase.UNCERTAIN, + ContactImportPhase.RETRY_REQUIRED, + ContactImportPhase.NOT_ADDED, + ContactImportPhase.IN_FLIGHT, + ), + ) + 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, + ).also { + database.metaDao().upsert(MetaEntity(key, encodeContactImport(it))) } - activeContactImports[epoch to key] = attempt.spec.clientId - attempt + } } + activeContactImports[epoch to key] = attempt.spec.clientId + attempt + } open suspend fun finishContactImport( spec: ContactSendSpec, epoch: Long, attempt: ContactImportAttempt, - ): Boolean = - draftWriteMutex.withLock { - val key = contactImportMetaKey(spec) - try { - if (!outgoingAccountIsCurrent(epoch)) return@withLock false - require(attempt.phase != ContactImportPhase.IN_FLIGHT) - val encoded = encodeContactImport(attempt) - decodeContactImport(encoded) - roomIo { - val database = checkNotNull(db) - database.withTransaction { - val current = - database - .metaDao() - .get(key) - ?.value - ?.let(::decodeContactImport) - check(current != null && current.spec == attempt.spec && current.phase == ContactImportPhase.IN_FLIGHT) - database.metaDao().upsert(MetaEntity(key, encoded)) - } + ): Boolean = draftWriteMutex.withLock { + val key = contactImportMetaKey(spec) + try { + if (!outgoingAccountIsCurrent(epoch)) return@withLock false + require(attempt.phase != ContactImportPhase.IN_FLIGHT) + val encoded = encodeContactImport(attempt) + decodeContactImport(encoded) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + val current = database.metaDao().get(key)?.value?.let(::decodeContactImport) + check(current != null && current.spec == attempt.spec && current.phase == ContactImportPhase.IN_FLIGHT) + database.metaDao().upsert(MetaEntity(key, encoded)) } - true - } finally { - activeContactImports.remove(epoch to key, attempt.spec.clientId) } + true + } finally { + activeContactImports.remove(epoch to key, attempt.spec.clientId) } + } open suspend fun enqueueOutgoingTextSend( row: OutgoingTextSendEntity, expectedEpoch: Long = outgoingAccountEpoch(), draftSnapshot: CloudDraftState? = null, consumeDraft: Boolean = true, - ): Boolean = - draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - roomIo { - val database = checkNotNull(db) - database.withTransaction { - val current = - draftSnapshot?.let { - require(it.draft.chatId.value == row.message.chatId) - readCloudDraftState( - 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 - } - writeCloudDraftState( + ): Boolean = draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + val current = draftSnapshot?.let { + require(it.draft.chatId.value == row.message.chatId) + readCloudDraftState( database, - current.localEdit( - CloudDraft(current.draft.chatId, current.draft.threadId), - markdown = false, - ), - ) - true + 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 } + writeCloudDraftState( + database, + current.localEdit( + CloudDraft(current.draft.chatId, current.draft.threadId), + markdown = false, + ), + ) + true } } + } open fun observeOutgoingMediaSends() = db?.outgoingMediaSendDao()?.observeAll() ?: flowOf(emptyList()) @@ -1563,105 +1421,84 @@ open class OfflineWarmup( 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 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, - ): Boolean = - draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - enqueueMediaLocked(send, draftSnapshot, consumeDraft) - } + ): Boolean = draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + enqueueMediaLocked(send, draftSnapshot, consumeDraft) + } private suspend fun enqueueMediaLocked( send: OutgoingMediaSend, draftSnapshot: CloudDraftState?, consumeDraft: Boolean, - ): Boolean = - roomIo { - val database = checkNotNull(db) - database.withTransaction { - val current = - draftSnapshot?.let { - require(it.draft.chatId.value == send.batch.chatId) - require( - it.draft.threadId == ( - send.items - .firstOrNull() - ?.message - ?.replyToTopId ?: 0 - ), - ) - readCloudDraftState( - database, - it.draft.chatId, - it.draft.threadId, - ).also { state -> check(!state.draft.hasUnsupportedContent) } - } - 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 - } - writeCloudDraftState( + ): Boolean = roomIo { + val database = checkNotNull(db) + database.withTransaction { + val current = draftSnapshot?.let { + require(it.draft.chatId.value == send.batch.chatId) + require( + it.draft.threadId == (send.items.firstOrNull()?.message?.replyToTopId ?: 0), + ) + readCloudDraftState( database, - current.localEdit( - CloudDraft(current.draft.chatId, current.draft.threadId), - markdown = false, - ), + it.draft.chatId, + it.draft.threadId, + ).also { state -> check(!state.draft.hasUnsupportedContent) } + } + send.items.forEach { + require( + database.outgoingTextSendDao().get(it.randomId) == null, ) - true } + 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 + } + writeCloudDraftState( + database, + current.localEdit( + CloudDraft(current.draft.chatId, current.draft.threadId), + markdown = false, + ), + ) + true } + } - open suspend fun recoverOutgoingMediaFiles(expectedEpoch: Long) = - draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(expectedEpoch)) - cleanupOutgoingMediaFilesLocked() - } + open suspend fun recoverOutgoingMediaFiles(expectedEpoch: Long) = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(expectedEpoch)) + cleanupOutgoingMediaFilesLocked() + } private suspend fun cleanupOutgoingMediaFilesLocked() { if (outgoingMediaFiles == null) return - val referenced = - roomIo { - checkNotNull(db) - .outgoingMediaSendDao() - .all() - .flatMap { it.items } - .map { it.source.name } - .toSet() - } + val referenced = roomIo { + checkNotNull(db).outgoingMediaSendDao().all().flatMap { it.items } + .map { it.source.name }.toSet() + } outgoingMediaFiles.removeUnreferenced(referenced) } @@ -1672,75 +1509,68 @@ open class OfflineWarmup( expectedEpoch: Long, draftSnapshot: CloudDraftState? = null, consumeDraft: Boolean = true, - ): OutgoingMediaAdmission = - draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(expectedEpoch)) - val files = checkNotNull(outgoingMediaFiles) - try { - val retained = files.retain(uploads.map { java.io.File(it.path) }, maxTotalBytes) - val items = - uploads.mapIndexed { index, upload -> - val path = "local:" + files.retainedPath(retained[index]) - OutgoingMediaItemEntity( - checkNotNull(upload.message.randomId), - batch.batchId, - index, - upload.mimeType, - retained[index], - upload.message.copy( - mediaCacheKey = path, - thumbCacheKey = path, - fileSize = retained[index].size, - ), - ) - } - val send = OutgoingMediaSend(batch, items) - val consumed = - withContext(NonCancellable) { - enqueueMediaLocked( - send, - draftSnapshot, - consumeDraft, - ) - } - OutgoingMediaAdmission(send, consumed) - } catch (error: Throwable) { - withContext(NonCancellable) { - try { - cleanupOutgoingMediaFilesLocked() - } catch (_: Exception) { - AppLog.warn("outgoing", "retained media recovery deferred") - } + ): OutgoingMediaAdmission = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(expectedEpoch)) + val files = checkNotNull(outgoingMediaFiles) + try { + val retained = files.retain(uploads.map { java.io.File(it.path) }, maxTotalBytes) + val items = uploads.mapIndexed { index, upload -> + val path = "local:" + files.retainedPath(retained[index]) + OutgoingMediaItemEntity( + checkNotNull(upload.message.randomId), + batch.batchId, + index, + upload.mimeType, + retained[index], + upload.message.copy( + mediaCacheKey = path, + thumbCacheKey = path, + fileSize = retained[index].size, + ), + ) + } + val send = OutgoingMediaSend(batch, items) + val consumed = withContext(NonCancellable) { + enqueueMediaLocked( + send, + draftSnapshot, + consumeDraft, + ) + } + OutgoingMediaAdmission(send, consumed) + } catch (error: Throwable) { + withContext(NonCancellable) { + try { + cleanupOutgoingMediaFilesLocked() + } catch (_: Exception) { + AppLog.warn("outgoing", "retained media recovery deferred") } - throw error } + throw error } + } 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() - } + open suspend fun retryOutgoingTextSend(randomId: Long) = roomIo { + if (db?.outgoingTextSendDao()?.retry(randomId) == 1) outgoingSendSync.requestResume() + } open suspend fun applyOutgoingMediaResults( batchId: Long, @@ -1756,21 +1586,17 @@ open class OfflineWarmup( 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(), + 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 - is OutgoingMessageResult.Scheduled -> result.message.message - } - require(message.id.chatId.value == send.batch.chatId && message.id.id > 0) - (result is OutgoingMessageResult.Scheduled) to message.id.id + val messageIds = results.map { (_, result) -> + val message = when (result) { + is OutgoingMessageResult.Sent -> result.message + is OutgoingMessageResult.Scheduled -> result.message.message } + require(message.id.chatId.value == send.batch.chatId && message.id.id > 0) + (result is OutgoingMessageResult.Scheduled) to message.id.id + } require(messageIds.toSet().size == messageIds.size) results.forEach { (randomId, result) -> applyOutgoingResult( @@ -1817,37 +1643,32 @@ open class OfflineWarmup( applyIncomingMessage(message, accountGeneration, acceptedOrder) } if (retained?.message?.mediaKind == "geo" && decodeLocationSend(retained.message.fileName).live) { - val place = - org.monogram.core.models.ServiceMedia - .geo(message.fileName) + 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 } - val session = - LiveLocationSession( - message.id, - randomId, - deadline, - phase, - ) - database - .metaDao() + val session = LiveLocationSession( + message.id, + randomId, + deadline, + phase, + ) + database.metaDao() .upsert(MetaEntity(key, encodeLiveLocation(session))) } else { require(decodeLiveLocation(existing.value).randomId == randomId) @@ -1856,11 +1677,10 @@ open class OfflineWarmup( } } } - val chatId = - when (result) { - is OutgoingMessageResult.Sent -> result.message.id.chatId.value - is OutgoingMessageResult.Scheduled -> result.message.message.id.chatId.value - } + val chatId = when (result) { + is OutgoingMessageResult.Sent -> result.message.id.chatId.value + is OutgoingMessageResult.Scheduled -> result.message.message.id.chatId.value + } database.outgoingTextSendDao().acknowledge(randomId, chatId) } } @@ -1877,96 +1697,87 @@ open class OfflineWarmup( open suspend fun liveLocation( id: MessageId, epoch: Long, - ): LiveLocationSession? = - draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(epoch)) - roomIo { - db?.metaDao()?.get(liveLocationKey(id))?.let { - decodeLiveLocation(it.value).also { session -> - readStoredPayload { - require(session.messageId == id) - } + ): LiveLocationSession? = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + roomIo { + db?.metaDao()?.get(liveLocationKey(id))?.let { + decodeLiveLocation(it.value).also { session -> + readStoredPayload { + require(session.messageId == id) } } } } + } open suspend fun transitionLiveLocation( id: MessageId, revision: Long, phase: LiveLocationPhase, epoch: Long, - ): Boolean = - draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(epoch)) - roomIo { - val database = checkNotNull(db) - database.withTransaction { - val key = liveLocationKey(id) + ): Boolean = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + val key = liveLocationKey(id) + val current = database.metaDao().get(key)?.let { decodeLiveLocation(it.value) } + ?: return@withTransaction false + require(current.messageId == id) + if (current.revision != revision || current.revision == Long.MAX_VALUE || !current.canTransition( + phase, + ) || (phase == LiveLocationPhase.ACTIVE && current.expired( + System.currentTimeMillis() / 1000, + )) + ) { + return@withTransaction false + } + database.metaDao().upsert( + MetaEntity( + key, + encodeLiveLocation( + current.copy( + phase = phase, + revision = revision + 1, + ), + ), + ), + ) + true + } + } + } + + open suspend fun recoverLiveLocations(epoch: Long) = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + for (key in database.metaDao().keysLike("live_location:%")) { val current = - database.metaDao().get(key)?.let { decodeLiveLocation(it.value) } - ?: return@withTransaction false - require(current.messageId == id) - if (current.revision != revision || current.revision == Long.MAX_VALUE || - !current.canTransition( - phase, - ) || ( - phase == LiveLocationPhase.ACTIVE && - current.expired( - System.currentTimeMillis() / 1000, - ) - ) - ) { - return@withTransaction false + decodeLiveLocation(checkNotNull(database.metaDao().get(key)).value) + require(key == liveLocationKey(current.messageId)) + val phase = when (current.phase) { + LiveLocationPhase.ACTIVE -> LiveLocationPhase.PAUSED + LiveLocationPhase.STOPPING -> LiveLocationPhase.UNCERTAIN_STOP + else -> continue } + check(current.revision < Long.MAX_VALUE) database.metaDao().upsert( MetaEntity( key, encodeLiveLocation( current.copy( phase = phase, - revision = revision + 1, + revision = current.revision + 1, ), ), ), ) - true - } - } - } - - open suspend fun recoverLiveLocations(epoch: Long) = - draftWriteMutex.withLock { - check(outgoingAccountIsCurrent(epoch)) - roomIo { - val database = checkNotNull(db) - database.withTransaction { - for (key in database.metaDao().keysLike("live_location:%")) { - val current = - decodeLiveLocation(checkNotNull(database.metaDao().get(key)).value) - require(key == liveLocationKey(current.messageId)) - val phase = - when (current.phase) { - LiveLocationPhase.ACTIVE -> LiveLocationPhase.PAUSED - LiveLocationPhase.STOPPING -> LiveLocationPhase.UNCERTAIN_STOP - else -> continue - } - check(current.revision < Long.MAX_VALUE) - database.metaDao().upsert( - MetaEntity( - key, - encodeLiveLocation( - current.copy( - phase = phase, - revision = current.revision + 1, - ), - ), - ), - ) - } } } } + } open suspend fun blockScheduledQueueUntil( deadline: Long, @@ -1989,47 +1800,44 @@ open class OfflineWarmup( open suspend fun scheduledQueueRevision( chatId: PeerId, expectedEpoch: Long, - ): Long = - draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - roomIo { checkNotNull(db).scheduledMessageDao().revision(chatId.value) } - } + ): Long = draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + roomIo { checkNotNull(db).scheduledMessageDao().revision(chatId.value) } + } open suspend fun replaceScheduledMessagesIfCurrent( chatId: PeerId, expectedRevision: Long, messages: List, expectedEpoch: Long, - ): Boolean = - draftWriteMutex.withLock { - check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) - roomIo { - checkNotNull(db).scheduledMessageDao().replaceIfCurrent( - chatId.value, - expectedRevision, - messages.map { - ScheduledMessageEntity( - it.message.toEntity(), - it.hasUnsupportedContent, - ) - }, - ) - } - } - - open suspend fun applyScheduledMessage(message: ScheduledMessage) = + ): Boolean = draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) roomIo { - db?.scheduledMessageDao()?.applyMessages( - message.message.id.chatId.value, - listOf( + checkNotNull(db).scheduledMessageDao().replaceIfCurrent( + chatId.value, + expectedRevision, + messages.map { ScheduledMessageEntity( - message.message.toEntity(), - message.hasUnsupportedContent, - ), - ), + it.message.toEntity(), + it.hasUnsupportedContent, + ) + }, ) - Unit } + } + + open suspend fun applyScheduledMessage(message: ScheduledMessage) = roomIo { + db?.scheduledMessageDao()?.applyMessages( + message.message.id.chatId.value, + listOf( + ScheduledMessageEntity( + message.message.toEntity(), + message.hasUnsupportedContent, + ), + ), + ) + Unit + } open suspend fun deleteScheduledMessages( chatId: PeerId, @@ -2039,50 +1847,49 @@ open class OfflineWarmup( Unit } - open suspend fun clearAccountCache() = - draftWriteMutex.withLock { - outgoingAccountActive = false - outgoingAccountEpoch.incrementAndGet() - draftSync.reset() - outgoingSendSync.reset() - roomIo { - recentWarmupJob?.cancel() - recentHistory.clear() - firstPaintSnapshot = null - recentWarmupChats = emptyList() - val database = db ?: return@roomIo - database.messageDao().clearAll() - database.scheduledMessageDao().clear() - database.outgoingTextSendDao().clear() - database.outgoingMediaSendDao().clear() - database.messageTombstoneDao().clear() - database.chatDao().clear() - database.folderDao().clear() - database.peerDao().clear() - database.updateCursorDao().clear() - database.updateCursorDao().clearReceipts() - database.profileTabsDao().clear() - database.profileMembersDao().clear() - database.profileMediaDao().clear() - database.profileCommonDao().clear() - database.messageHoleDao().clearAll() - database.metaDao().deletePrefix("history_coverage:") - database.metaDao().deleteLike("discussion_read:%") - database.metaDao().deleteLike("${DRAFT_META_PREFIX}%") - database.metaDao().deleteLike("cloud_draft:%") - database.metaDao().deleteLike("outgoing_send.%") - database.metaDao().deleteLike("contact_import:%") - database.metaDao().deleteLike("live_location:%") - activeContactImports.clear() - } - try { - outgoingMediaFiles?.removeUnreferenced(emptySet()) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - AppLog.warn("outgoing", "account media cleanup deferred") - } + open suspend fun clearAccountCache() = draftWriteMutex.withLock { + outgoingAccountActive = false + outgoingAccountEpoch.incrementAndGet() + draftSync.reset() + outgoingSendSync.reset() + roomIo { + recentWarmupJob?.cancel() + recentHistory.clear() + firstPaintSnapshot = null + recentWarmupChats = emptyList() + val database = db ?: return@roomIo + database.messageDao().clearAll() + database.scheduledMessageDao().clear() + database.outgoingTextSendDao().clear() + database.outgoingMediaSendDao().clear() + database.messageTombstoneDao().clear() + database.chatDao().clear() + database.folderDao().clear() + database.peerDao().clear() + database.updateCursorDao().clear() + database.updateCursorDao().clearReceipts() + database.profileTabsDao().clear() + database.profileMembersDao().clear() + database.profileMediaDao().clear() + database.profileCommonDao().clear() + database.messageHoleDao().clearAll() + database.metaDao().deletePrefix("history_coverage:") + database.metaDao().deleteLike("discussion_read:%") + database.metaDao().deleteLike("${DRAFT_META_PREFIX}%") + database.metaDao().deleteLike("cloud_draft:%") + database.metaDao().deleteLike("outgoing_send.%") + database.metaDao().deleteLike("contact_import:%") + database.metaDao().deleteLike("live_location:%") + activeContactImports.clear() + } + try { + outgoingMediaFiles?.removeUnreferenced(emptySet()) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("outgoing", "account media cleanup deferred") } + } open suspend fun setDialogScroll( chatId: PeerId, @@ -2120,12 +1927,11 @@ open class OfflineWarmup( } /** Users are positive; groups/channels are negative. Unknown non-user peers start unjoined. */ -internal fun placeholderChat(chatId: PeerId) = - Chat( - id = chatId, - title = "", - left = chatId.value < 0, - ) +internal fun placeholderChat(chatId: PeerId) = Chat( + id = chatId, + title = "", + left = chatId.value < 0, +) data class HistoryPrune( val chatId: Long, 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 7e20a0fc6..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 @@ -111,7 +111,9 @@ 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 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/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/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/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 abdf3239d..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 @@ -39,11 +39,8 @@ 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', '_', '-', ',', '.') - .mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() } } + val letters = title.trim().split(' ', '\t', '\n', '_', '-', ',', '.') + .mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() } } return when { letters.size >= 2 -> "${letters[0].uppercaseChar()}${letters[1].uppercaseChar()}" letters.size == 1 -> letters[0].uppercaseChar().toString() @@ -60,11 +57,10 @@ fun ServiceAvatar( size: Dp = 52.dp, ) { Box( - modifier = - modifier - .size(size) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), + modifier = modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center, ) { Icon( @@ -92,20 +88,14 @@ fun rememberAvatarVideoPoster( 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 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 } } @@ -120,26 +110,22 @@ fun PeerAvatar( 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) - .clip(CircleShape) - .background(avatarBg.copy(alpha = 0.22f)), + modifier = modifier + .size(size) + .clip(CircleShape) + .background(avatarBg.copy(alpha = 0.22f)), contentAlignment = Alignment.Center, ) { Text( text = initial, - style = - MaterialTheme.typography.titleMedium.copy( - fontSize = if (size >= 80.dp) 34.sp else (size.value * 0.36f).sp, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ), + style = MaterialTheme.typography.titleMedium.copy( + fontSize = if (size >= 80.dp) 34.sp else (size.value * 0.36f).sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + ), color = avatarBg, ) if (placeholder != null) { @@ -147,7 +133,7 @@ fun PeerAvatar( painter = placeholder, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, ) } val file = imageFile?.takeIf { it.exists() && it.length() > 0L } @@ -156,49 +142,36 @@ fun PeerAvatar( if (webmFile(file) || mp4File(file)) { LoopingVideo( file = file, - modifier = - Modifier - .fillMaxSize() - .clip(CircleShape), + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), crop = true, ) } 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() - .clip(CircleShape), + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), contentScale = ContentScale.Crop, ) } } else if (preview != null) { val context = LocalContext.current AsyncImage( - model = - ImageRequest - .Builder(context) - .data(preview) - .memoryCacheKey("peer-stripped:${preview.size}:${preview.contentHashCode()}") - .diskCachePolicy(CachePolicy.DISABLED) - .crossfade(false) - .size(128) - .transformations(PreviewBlurTransformation()) - .build(), + model = ImageRequest.Builder(context).data(preview) + .memoryCacheKey("peer-stripped:${preview.size}:${preview.contentHashCode()}") + .diskCachePolicy(CachePolicy.DISABLED).crossfade(false).size(128) + .transformations(PreviewBlurTransformation()).build(), contentDescription = null, - modifier = - Modifier - .fillMaxSize() - .clip(CircleShape), + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), contentScale = ContentScale.Crop, ) } 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 156ea3414..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 @@ -59,93 +59,88 @@ fun SettingsCard( onLongClickLabel: String? = null, content: @Composable ColumnScope.() -> Unit, ) { - val shape = - when (position) { - ItemPosition.TOP -> { - RoundedCornerShape( - topStart = SettingsGroupCorner, - topEnd = SettingsGroupCorner, - ) - } + val shape = when (position) { + ItemPosition.TOP -> { + RoundedCornerShape( + topStart = SettingsGroupCorner, + topEnd = SettingsGroupCorner, + ) + } - ItemPosition.MIDDLE -> { - RoundedCornerShape(0.dp) - } + ItemPosition.MIDDLE -> { + RoundedCornerShape(0.dp) + } - ItemPosition.BOTTOM -> { - RoundedCornerShape( - bottomStart = SettingsGroupCorner, - bottomEnd = SettingsGroupCorner, - ) - } + ItemPosition.BOTTOM -> { + RoundedCornerShape( + bottomStart = SettingsGroupCorner, + bottomEnd = SettingsGroupCorner, + ) + } - ItemPosition.STANDALONE -> { - RoundedCornerShape(SettingsGroupCorner) - } + ItemPosition.STANDALONE -> { + RoundedCornerShape(SettingsGroupCorner) } + } val container by animateColorAsState( - targetValue = - if (selected == true) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerLow - }, + targetValue = if (selected == true) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerLow + }, label = "settingsCardContainer", ) - val contentColor = - if (selected == true) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - } + val contentColor = if (selected == true) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } Column(modifier = modifier.fillMaxWidth()) { if (position == ItemPosition.MIDDLE || position == ItemPosition.BOTTOM) { Box( - modifier = - Modifier - .padding(start = DividerInset) - .fillMaxWidth() - .height(1.dp) - .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = Modifier + .padding(start = DividerInset) + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), ) } Surface( color = container, contentColor = contentColor, shape = shape, - modifier = - Modifier - .fillMaxWidth() - .clip(shape) - .alpha(if (enabled) 1f else 0.5f) - .then( - when { - onLongClick != null && enabled -> { - Modifier.combinedClickable( - onClick = { onClick?.invoke() }, - onLongClick = onLongClick, - onLongClickLabel = onLongClickLabel, - ) - } + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .alpha(if (enabled) 1f else 0.5f) + .then( + when { + onLongClick != null && enabled -> { + Modifier.combinedClickable( + onClick = { onClick?.invoke() }, + onLongClick = onLongClick, + onLongClickLabel = onLongClickLabel, + ) + } - onClick != null && enabled -> { - Modifier.clickable(onClick = onClick) - } + onClick != null && enabled -> { + Modifier.clickable(onClick = onClick) + } - else -> { - Modifier - } - }, - ) - .then( - if (selected != null) { - Modifier.semantics { - this.selected = selected - } - } else { + else -> { Modifier - }, - ), + } + }, + ) + .then( + if (selected != null) { + Modifier.semantics { + this.selected = selected + } + } else { + Modifier + }, + ), ) { CompositionLocalProvider(LocalContentColor provides contentColor) { Column(content = content) @@ -173,42 +168,39 @@ fun SettingsCardRow( 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, - ) - } + 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) - } + onClick != null -> { + Modifier.clickable(enabled = enabled, onClick = onClick) + } - else -> { - Modifier - } + else -> { + Modifier } + } Row( - modifier = - modifier - .fillMaxWidth() - .alpha(if (enabled) 1f else 0.5f) - .then(interaction) - .padding(horizontal = RowHorizontalPadding, vertical = RowVerticalPadding), + modifier = modifier + .fillMaxWidth() + .alpha(if (enabled) 1f else 0.5f) + .then(interaction) + .padding(horizontal = RowHorizontalPadding, vertical = RowVerticalPadding), verticalAlignment = Alignment.CenterVertically, ) { if (leadingContent != null) { leadingContent() } else { Box( - modifier = - Modifier - .size(RowIconSize) - .background(color = iconColor.copy(alpha = 0.16f), shape = CircleShape), + modifier = Modifier + .size(RowIconSize) + .background(color = iconColor.copy(alpha = 0.16f), shape = CircleShape), contentAlignment = Alignment.Center, ) { Icon( @@ -242,10 +234,9 @@ fun SettingsCardRow( Icon( imageVector = Icons.Outlined.Check, contentDescription = null, - modifier = - Modifier - .size(20.dp) - .alpha(if (selected) 1f else 0f), + modifier = Modifier + .size(20.dp) + .alpha(if (selected) 1f else 0f), ) } } 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/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/main/java/org/monogram/feature/chats/ChatsExecutor.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsExecutor.kt index 99a00f6cb..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 @@ -120,12 +120,8 @@ internal class ChatsExecutor( 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 @@ -145,9 +141,7 @@ internal class ChatsExecutor( } scope.launch { loadNotifyDefaults() } refresh(force = false) - client - .updates() - .onEach { update -> + client.updates().onEach { update -> when (update) { is MtprotoUpdate.SyncState -> { protocolSyncing.value = update.isSyncing @@ -156,12 +150,11 @@ internal class ChatsExecutor( is MtprotoUpdate.ChatsChanged -> { if (update.chats.isNotEmpty()) { - val mapped = - withEffectiveMutes( - update.chats, - notifyDefaults, - notifyDefaultsLoaded, - ) + val mapped = withEffectiveMutes( + update.chats, + notifyDefaults, + notifyDefaultsLoaded, + ) // Capture, merge, and publish under one lock. If refresh or an // incoming-message rebuild owns it, this update waits before taking // its snapshot, so it cannot queue a stale replacement behind them. @@ -170,12 +163,11 @@ internal class ChatsExecutor( val before = listedById() val listed = allListed() refreshMergeHook?.invoke() - val rebuilt = - if (listed.size > 64) { - withContext(ioContext) { mergeChats(listed, mapped) } - } else { - mergeChats(listed, mapped) - } + val rebuilt = if (listed.size > 64) { + withContext(ioContext) { mergeChats(listed, mapped) } + } else { + mergeChats(listed, mapped) + } republishListedLocked( rebuilt, fromCache = false, @@ -183,21 +175,10 @@ internal class ChatsExecutor( keep = paintKeep(), ) val rebuiltById = rebuilt.associateBy { it.id } - changed = - mapped - .filter { 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 } + changed = mapped.filter { 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) } } @@ -219,14 +200,12 @@ internal class ChatsExecutor( is MtprotoUpdate.PeerStatus -> { val current = state().chats.firstOrNull { it.id == update.userId } - val visible = - current == null || - LastSeen.affectsUi( - current.peerStatus, - current.peerStatusAt, - update.status, - update.statusAt, - ) + val visible = current == null || LastSeen.affectsUi( + current.peerStatus, + current.peerStatusAt, + update.status, + update.statusAt, + ) if (current != null && visible) { PeerPresence.record( update.userId.value, @@ -234,14 +213,12 @@ internal class ChatsExecutor( update.statusAt, ) } - val dotChanged = - current != null && - onlineDotChanged( - current.peerStatus, - current.peerStatusAt, - update.status, - update.statusAt, - ) + val dotChanged = current != null && onlineDotChanged( + current.peerStatus, + current.peerStatusAt, + update.status, + update.statusAt, + ) if (dotChanged) { listPublicationMutex.withLock { dispatch(Msg.Status(update.userId, update.status, update.statusAt)) @@ -298,11 +275,9 @@ internal class ChatsExecutor( 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 - } + val affected = state().chats.filter { chat -> + (update.chatId == null || update.chatId == chat.id) && chat.lastMessageId in ids + } for (chat in affected) { val latest = warmup?.messages(chat.id, 1)?.firstOrNull() dispatch(Msg.LatestReplaced(chat.id, latest)) @@ -313,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( @@ -345,8 +320,7 @@ internal class ChatsExecutor( is MtprotoUpdate.UnreadMentions -> { val current = state().chats.firstOrNull { it.id == update.chatId } - if (current == null || current.unreadMentionsCount != - update.stillUnread.coerceAtLeast( + if (current == null || current.unreadMentionsCount != update.stillUnread.coerceAtLeast( 0, ) ) { @@ -358,8 +332,7 @@ internal class ChatsExecutor( is MtprotoUpdate.UnreadReactions -> { val current = state().chats.firstOrNull { it.id == update.chatId } - if (current == null || current.unreadReactionsCount != - update.stillUnread.coerceAtLeast( + if (current == null || current.unreadReactionsCount != update.stillUnread.coerceAtLeast( 0, ) ) { @@ -416,9 +389,7 @@ internal class ChatsExecutor( } } }.launchIn(scope) - client - .sessionLost() - .onEach { + client.sessionLost().onEach { // Local sign-out signal; the real cause was logged when it died. dispatch(Msg.Error(TelegramError.sessionInvalidated())) }.launchIn(scope) @@ -445,8 +416,7 @@ internal class ChatsExecutor( // the shared loading gate. val wire = wireFolderId(intent.folderId) val needsInitial = - changed && isFolderScopedStream(intent.folderId) && - folderPaging[wire]?.started != true + changed && isFolderScopedStream(intent.folderId) && folderPaging[wire]?.started != true if (needsInitial) { dispatch(Msg.HasMore(true)) if (state().loadingMore) { @@ -506,47 +476,42 @@ internal class ChatsExecutor( * Peer type defaults are what a dialog without its own mute setting inherits * (`account.getNotifySettings` for users / chats / broadcasts). */ - private suspend fun loadNotifyDefaults(force: Boolean = false) = - notifyDefaultsMutex.withLock { - if (notifyDefaultsRefreshed && !force) return@withLock - val loggedOut = - sessionStore?.isAuthorized() == false && - client.isLocallyAuthorized() != Outcome.Ok(true) - if (loggedOut) return@withLock - 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}", - ) - listPublicationMutex.withLock { - val current = state().chats - if (current.isNotEmpty()) { - val remapped = withEffectiveMutes(current, notifyDefaults, notifyDefaultsLoaded) - if (remapped != current) { - dispatch(Msg.Chats(remapped, fromCache = false, replace = true)) - // Keep the cache honest: a restart reads it before the defaults arrive again. - warmup?.upsertChats(remapped) - } + private suspend fun loadNotifyDefaults(force: Boolean = false) = notifyDefaultsMutex.withLock { + if (notifyDefaultsRefreshed && !force) return@withLock + val loggedOut = + sessionStore?.isAuthorized() == false && client.isLocallyAuthorized() != Outcome.Ok(true) + if (loggedOut) return@withLock + 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}", + ) + listPublicationMutex.withLock { + val current = state().chats + if (current.isNotEmpty()) { + val remapped = withEffectiveMutes(current, notifyDefaults, notifyDefaultsLoaded) + if (remapped != current) { + dispatch(Msg.Chats(remapped, fromCache = false, replace = true)) + // Keep the cache honest: a restart reads it before the defaults arrive again. + warmup?.upsertChats(remapped) } } } + } private suspend fun notifySettingsFor(kind: String): NotifySettings? = when (val result = client.getNotifySettings(kind)) { @@ -563,47 +528,42 @@ internal class ChatsExecutor( muteUntil: Int, specified: Boolean, ) { - val updated = - listPublicationMutex.withLock { - val current = state().chats.firstOrNull { it.id == chatId } ?: return - val next = - if (specified) { - current.withOwnMute(muteUntil, nowEpochSeconds()) - } else if (current.muteOverride || !notifyDefaultsLoaded) { - current - } else { - withEffectiveMutes(listOf(current), notifyDefaults, true).single() - } - if (next == current) return - dispatch(Msg.Chats(listOf(next), fromCache = false, replace = false)) - next + val updated = listPublicationMutex.withLock { + val current = state().chats.firstOrNull { it.id == chatId } ?: return + val next = if (specified) { + current.withOwnMute(muteUntil, nowEpochSeconds()) + } else if (current.muteOverride || !notifyDefaultsLoaded) { + current + } else { + withEffectiveMutes(listOf(current), notifyDefaults, true).single() } + if (next == current) return + dispatch(Msg.Chats(listOf(next), fromCache = false, replace = false)) + next + } warmup?.upsertChats(listOf(updated)) } - private fun wireFolderId(folderId: Int?): Int = - when (folderId) { - null -> MAIN_FOLDER_WIRE_ID - ARCHIVE_FOLDER_ID -> ARCHIVE_FOLDER_WIRE_ID - else -> if (folderId in folderStreamRejected) MAIN_FOLDER_WIRE_ID else folderId - } + private fun wireFolderId(folderId: Int?): Int = when (folderId) { + null -> MAIN_FOLDER_WIRE_ID + ARCHIVE_FOLDER_ID -> ARCHIVE_FOLDER_WIRE_ID + else -> if (folderId in folderStreamRejected) MAIN_FOLDER_WIRE_ID else folderId + } /** A folder has its own server-side dialog stream unless the server refused its id. */ - private fun isFolderScopedStream(folderId: Int?): Boolean = - when (folderId) { - null, MAIN_FOLDER_WIRE_ID -> false - ARCHIVE_FOLDER_ID -> true - else -> folderId !in folderStreamRejected - } + private fun isFolderScopedStream(folderId: Int?): Boolean = when (folderId) { + null, MAIN_FOLDER_WIRE_ID -> false + ARCHIVE_FOLDER_ID -> true + else -> folderId !in folderStreamRejected + } /** A custom folder needs several main-stream pages per gesture to surface new members. */ private fun pagesPerRequest(folderId: Int?): Int = if (folderId != null && folderId != ARCHIVE_FOLDER_ID) CUSTOM_FOLDER_PAGES else 1 - private fun fallbackArchiveFolderId(folderId: Int?): Int? = - folderId?.takeIf { - it != ARCHIVE_FOLDER_ID && it in folderStreamRejected - } + private fun fallbackArchiveFolderId(folderId: Int?): Int? = folderId?.takeIf { + it != ARCHIVE_FOLDER_ID && it in folderStreamRejected + } private fun hasMoreFor(folderId: Int?): Boolean { val wire = wireFolderId(folderId) @@ -618,8 +578,7 @@ internal class ChatsExecutor( } folderId == ARCHIVE_FOLDER_ID -> { - cachedTail.any { it.isArchiveListRow() } || - (folderPaging[wire]?.hasMore ?: true) + cachedTail.any { it.isArchiveListRow() } || (folderPaging[wire]?.hasMore ?: true) } else -> { @@ -653,14 +612,9 @@ internal class ChatsExecutor( if (message.outgoing) return message if (!message.senderName.isNullOrBlank()) return message val senderId = message.senderId?.value ?: return message - val title = - withContext(ioContext) { - sessionStore - ?.readProfile(senderId) - ?.title - ?.trim() - .orEmpty() - } + val title = withContext(ioContext) { + sessionStore?.readProfile(senderId)?.title?.trim().orEmpty() + } return if (title.isEmpty()) message else message.copy(senderName = title) } @@ -721,18 +675,17 @@ internal class ChatsExecutor( "cache hit count=${cached.size} main=$roomMainCount", ) val muted = muteChats(cached) - val (first, tail) = - if (!hasMemory) { - if (muted.size > 64) { - withContext(ioContext) { paintDialogsWindow(muted) } - } else { - paintDialogsWindow(muted) - } - } else if (muted.size > 64) { - withContext(ioContext) { sortChats(muted) } to emptyList() + val (first, tail) = if (!hasMemory) { + if (muted.size > 64) { + withContext(ioContext) { paintDialogsWindow(muted) } } else { - sortChats(muted) to emptyList() + paintDialogsWindow(muted) } + } else if (muted.size > 64) { + withContext(ioContext) { sortChats(muted) } to emptyList() + } else { + sortChats(muted) to emptyList() + } dispatch( Msg.Chats( first, @@ -746,25 +699,21 @@ internal class ChatsExecutor( recoverMissingTitles() if (cachedTail.none { it.isMainListRow() }) { val tail = listTail(first, archived = false) - val extra = - if (warmup == null || tail == null) { - emptyList() - } else { - warmup.chatsAfter(ChatCacheCursor.from(tail), DIALOGS_NETWORK_PAGE) - }.filter { it.isMainListRow() } + val extra = if (warmup == null || tail == null) { + emptyList() + } else { + warmup.chatsAfter(ChatCacheCursor.from(tail), DIALOGS_NETWORK_PAGE) + }.filter { it.isMainListRow() } cachedTail = extra + cachedTail } val loadedMain = - first.count { it.isMainListRow() } + - cachedTail.count { it.isMainListRow() } + 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 -> @@ -778,16 +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. - var result = - coroutineScope { - val dialogs = async { getChatsTimed("get") } - if (!notifyDefaultsLoaded) loadNotifyDefaults() - dialogs.await() - } - 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") } @@ -803,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 @@ -814,30 +758,27 @@ internal class ChatsExecutor( listPublicationMutex.withLock { val source = allListed() refreshMergeHook?.invoke() - val merged = - if (source.size > 64) { - withContext(ioContext) { - withNetworkPinOrder(mergeChats(source, mapped), mapped) - } - } else { + val merged = if (source.size > 64) { + withContext(ioContext) { withNetworkPinOrder(mergeChats(source, mapped), mapped) } + } else { + withNetworkPinOrder(mergeChats(source, mapped), mapped) + } persisted = merged val mergedById = merged.associateBy { it.id } - pruneRows = - mapped.filter { net -> - val local = mergedById[net.id] - local == null || net.lastMessageId >= local.lastMessageId - } + pruneRows = mapped.filter { net -> + val local = mergedById[net.id] + local == null || net.lastMessageId >= local.lastMessageId + } republishListedLocked( merged, fromCache = false, - hasMore = - if (activeFolderId == ARCHIVE_FOLDER_ID) { - null - } else { - dialogsHasMore(result.value.size) - }, + hasMore = if (activeFolderId == ARCHIVE_FOLDER_ID) { + null + } else { + dialogsHasMore(result.value.size) + }, keep = paintKeep(), ) } @@ -845,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() @@ -860,9 +799,9 @@ 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(",") + }", ) } } @@ -881,21 +820,18 @@ internal class ChatsExecutor( setRefreshSyncing(false) catalogSeed?.let { seed -> if (generation == catalogGeneration) { - 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") - } + 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) { @@ -929,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 { @@ -944,15 +880,14 @@ internal class ChatsExecutor( scope.launch { try { val tail = listTail(state().chats + cachedTail, archived = true) - val roomPage = - if (tail == null) { - emptyList() - } else { - warmup.chatsAfter( - ChatCacheCursor.from(tail), - DIALOGS_NETWORK_PAGE, - ) - }.filter { it.isArchiveListRow() } + val roomPage = if (tail == null) { + emptyList() + } else { + warmup.chatsAfter( + ChatCacheCursor.from(tail), + DIALOGS_NETWORK_PAGE, + ) + }.filter { it.isArchiveListRow() } AppLog.api("chats", "loadMore archive room count=${roomPage.size}") if (roomPage.isNotEmpty()) { dispatch(Msg.Append(roomPage)) @@ -999,12 +934,11 @@ internal class ChatsExecutor( scope.launch { try { val tail = listTail(state().chats + cachedTail, archived = false) - val roomPage = - if (tail == null) { - emptyList() - } else { - warmup.chatsAfter(ChatCacheCursor.from(tail), DIALOGS_NETWORK_PAGE) - }.filter { it.isMainListRow() } + val roomPage = if (tail == null) { + emptyList() + } else { + warmup.chatsAfter(ChatCacheCursor.from(tail), DIALOGS_NETWORK_PAGE) + }.filter { it.isMainListRow() } AppLog.api("chats", "loadMore room count=${roomPage.size}") if (roomPage.isNotEmpty()) { dispatch(Msg.Append(roomPage)) @@ -1036,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") @@ -1063,39 +997,35 @@ internal class ChatsExecutor( val wire = wireFolderId(requestFolderId) val archiveFallback = fallbackArchiveFolderId(requestFolderId) val useArchiveFallback = - archiveFallback != null && - folderArchivePaging[archiveFallback]?.hasMore != false && - (!mainNetworkHasMore || fetched % 2 == 0) + archiveFallback != null && folderArchivePaging[archiveFallback]?.hasMore != false && (!mainNetworkHasMore || fetched % 2 == 0) val folderScoped = isFolderScopedStream(requestFolderId) val pageBudget = pagesPerRequest(requestFolderId) - val stateful = - when { - useArchiveFallback -> folderArchivePaging.getOrPut(archiveFallback) { FolderPaging() } - folderScoped -> folderPaging.getOrPut(wire) { FolderPaging() } - else -> null + val stateful = when { + useArchiveFallback -> folderArchivePaging.getOrPut(archiveFallback) { FolderPaging() } + folderScoped -> folderPaging.getOrPut(wire) { FolderPaging() } + else -> null + } + val cursor = when { + stateful != null && !stateful.started -> { + Triple(0, 0, 0L) } - val cursor = - when { - stateful != null && !stateful.started -> { - Triple(0, 0, 0L) - } - stateful != null -> { - dialogsPageCursor( - stateful.page, - failedOffsetPeers, - skipArchived = false, - ) - } + 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) { stateful.hasMore = false @@ -1103,12 +1033,11 @@ internal class ChatsExecutor( mainNetworkHasMore = false } val otherStreamHasMore = - archiveFallback != null && - if (useArchiveFallback) { - mainNetworkHasMore - } else { - folderArchivePaging[archiveFallback]?.hasMore == true - } + 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. @@ -1117,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) { @@ -1135,15 +1063,13 @@ 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) - } else { - client.loadMoreChats(date, offsetId, peerId) - } + val result = if (useArchiveFallback || folderScoped) { + client.loadMoreFolderChats(requestFolder, date, offsetId, peerId) + } else { + client.loadMoreChats(date, offsetId, peerId) + } if (requestGeneration != folderSelectionGeneration || requestFolderId != activeFolderId) { paged = true return@run @@ -1170,8 +1096,7 @@ internal class ChatsExecutor( dispatch( Msg.HasMore( if (archiveFallback != null) { - mainNetworkHasMore || - (folderArchivePaging[archiveFallback]?.hasMore == true) + mainNetworkHasMore || (folderArchivePaging[archiveFallback]?.hasMore == true) } else { mainNetworkHasMore }, @@ -1179,12 +1104,11 @@ internal class ChatsExecutor( ) } AppLog.api("chats", "loadMore network count=${page.size}") - val mapped = - withEffectiveMutes( - page, - notifyDefaults, - notifyDefaultsLoaded, - ) + val mapped = withEffectiveMutes( + page, + notifyDefaults, + notifyDefaultsLoaded, + ) dispatch(Msg.Append(mapped)) if (mapped.isNotEmpty()) { warmup?.upsertChats(mapped) @@ -1231,8 +1155,7 @@ internal class ChatsExecutor( if (archiveFallback != null) { dispatch( Msg.HasMore( - mainNetworkHasMore || - (folderArchivePaging[archiveFallback]?.hasMore == true), + mainNetworkHasMore || (folderArchivePaging[archiveFallback]?.hasMore == true), ), ) } else { @@ -1248,8 +1171,7 @@ internal class ChatsExecutor( } if (!paged) { val stillCurrent = - requestGeneration == folderSelectionGeneration && - requestFolderId == activeFolderId + requestGeneration == folderSelectionGeneration && requestFolderId == activeFolderId val archiveFallback = fallbackArchiveFolderId(requestFolderId) if (!stillCurrent) { // A replacement folder owns the next loading transition. @@ -1274,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() } } @@ -1305,32 +1224,28 @@ internal class ChatsExecutor( } val chat = state().chats.firstOrNull { it.id == chatId } val named = chat?.isGroup == true || chat?.isChannel == true - val name = - if (named) { - sessionStore?.readProfile(userId.value)?.title - ?: state().chats.firstOrNull { it.id == userId }?.title - ?: chatMap[userId.value]?.name - ?: "" - } else { - "" - } + val name = if (named) { + sessionStore?.readProfile(userId.value)?.title + ?: state().chats.firstOrNull { it.id == userId }?.title + ?: chatMap[userId.value]?.name ?: "" + } else { + "" + } chatMap.remove(userId.value) - chatMap[userId.value] = - TypingPresence( - name = name, - action = update.action.ifBlank { ChatActionKind.Typing.wire }, - ) + chatMap[userId.value] = TypingPresence( + name = name, + action = update.action.ifBlank { ChatActionKind.Typing.wire }, + ) dispatch(typingMessage(chatId)) - typingJobs[jobKey] = - scope.launch { - delay(6_000.milliseconds) - typingJobs.remove(jobKey) - typingByChat[chatId.value]?.remove(userId.value) - if (typingByChat[chatId.value].isNullOrEmpty()) { - typingByChat.remove(chatId.value) - } - dispatch(typingMessage(chatId)) + typingJobs[jobKey] = scope.launch { + delay(6_000.milliseconds) + typingJobs.remove(jobKey) + typingByChat[chatId.value]?.remove(userId.value) + if (typingByChat[chatId.value].isNullOrEmpty()) { + typingByChat.remove(chatId.value) } + dispatch(typingMessage(chatId)) + } } private fun allListed(): List = state().chats + cachedTail @@ -1381,12 +1296,11 @@ internal class ChatsExecutor( keep: Int, ) { val archiveLimit = archiveKeep(merged) - val (first, tail) = - if (merged.size > 64) { - withContext(ioContext) { paintDialogsWindow(merged, keep, archiveLimit) } - } else { - paintDialogsWindow(merged, keep, archiveLimit) - } + val (first, tail) = if (merged.size > 64) { + withContext(ioContext) { paintDialogsWindow(merged, keep, archiveLimit) } + } else { + paintDialogsWindow(merged, keep, archiveLimit) + } cachedTail = tail val painted = state().chats val sameWindow = painted == first @@ -1407,70 +1321,62 @@ internal class ChatsExecutor( ): Boolean { if (activeFolderId == ARCHIVE_FOLDER_ID) { val archiveHasMore = - hasMoreOverride - ?: (folderPaging[ARCHIVE_FOLDER_WIRE_ID]?.hasMore ?: true) + 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() { if (titleRecoveryJob?.isActive == true) return - titleRecoveryJob = - scope.launch { - // Resolve sequentially, once per refresh, so missing peers cannot flood the RPC queue. - while (true) { - val missing = - state().chats.firstOrNull { - it.id !in attemptedTitleRecovery && - isPlaceholderPeerTitle( - it.title, - it.id.value, - ) - } ?: break - attemptedTitleRecovery += missing.id - val cached = sessionStore?.readProfile(missing.id.value) - val profile = - cached?.takeUnless { isPlaceholderPeerTitle(it.title, it.id.value) } - ?: when (val result = client.getProfile(missing.id)) { - is Outcome.Ok -> result.value - is Outcome.Err -> continue - } - if (isPlaceholderPeerTitle(profile.title, missing.id.value)) continue - sessionStore?.upsertProfile(profile) - // Re-read after the RPC: messages and read state may have changed while waiting. - val updated = - listPublicationMutex.withLock { - val current = listedChat(missing.id) ?: return@withLock null - if (!isPlaceholderPeerTitle( - current.title, - current.id.value, - ) - ) { - 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, - isVerified = profile.isVerified, - ) - dispatch(Msg.Chats(listOf(next), fromCache = false, replace = false)) - next - } ?: continue - warmup?.upsertChats(listOf(updated)) - } + titleRecoveryJob = scope.launch { + // Resolve sequentially, once per refresh, so missing peers cannot flood the RPC queue. + while (true) { + val missing = state().chats.firstOrNull { + it.id !in attemptedTitleRecovery && isPlaceholderPeerTitle( + it.title, + it.id.value, + ) + } ?: break + attemptedTitleRecovery += missing.id + val cached = sessionStore?.readProfile(missing.id.value) + val profile = cached?.takeUnless { isPlaceholderPeerTitle(it.title, it.id.value) } + ?: when (val result = client.getProfile(missing.id)) { + is Outcome.Ok -> result.value + is Outcome.Err -> continue + } + if (isPlaceholderPeerTitle(profile.title, missing.id.value)) continue + sessionStore?.upsertProfile(profile) + // Re-read after the RPC: messages and read state may have changed while waiting. + val updated = listPublicationMutex.withLock { + val current = listedChat(missing.id) ?: return@withLock null + if (!isPlaceholderPeerTitle( + current.title, + current.id.value, + ) + ) { + return@withLock null + } + val next = current.copy( + title = profile.title, + isChannel = profile.kind == "channel", + isGroup = profile.kind == "group" || profile.kind == "chat", + 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)) + next + } ?: continue + warmup?.upsertChats(listOf(updated)) } + } } - private fun absorbIncoming( message: Message, accountGeneration: Long = 0, @@ -1478,53 +1384,44 @@ internal class ChatsExecutor( ) { scope.launch { var persistHidden: Chat? = null - val published = - listPublicationMutex.withLock { - val current = listedChat(message.id.chatId) - when { - current == null -> { - false - } + val published = listPublicationMutex.withLock { + val current = listedChat(message.id.chatId) + when { + current == null -> { + false + } - !current.isShownInChatList() -> { - persistHidden = - current.withIncomingMessage( - message, - accountGeneration, - acceptedOrder - ) - true - } + !current.isShownInChatList() -> { + persistHidden = current.withIncomingMessage( + message, accountGeneration, acceptedOrder + ) + true + } - else -> { - cachedTail = cachedTail.filter { it.id != current.id } - val listed = - buildList { - addAll(state().chats) - addAll(cachedTail) - if (none { it.id == current.id }) add(current) - } - republishListedLocked( - applyIncomingMessage( - listed, - message, - accountGeneration, - acceptedOrder - ), - fromCache = false, - hasMore = null, - keep = paintKeep(), - ) - true + else -> { + cachedTail = cachedTail.filter { it.id != current.id } + val listed = buildList { + addAll(state().chats) + addAll(cachedTail) + if (none { it.id == current.id }) add(current) } + republishListedLocked( + applyIncomingMessage( + listed, message, accountGeneration, acceptedOrder + ), + fromCache = false, + hasMore = null, + keep = paintKeep(), + ) + true } } + } persistHidden?.let { warmup?.upsertChats(listOf(it)) } if (published) return@launch - val stored = - withContext(ioContext) { - warmup?.chat(message.id.chatId) - } ?: return@launch + val stored = withContext(ioContext) { + warmup?.chat(message.id.chatId) + } ?: return@launch if (!stored.isMainListRow()) { warmup?.upsertChats( listOf( @@ -1565,21 +1462,17 @@ internal class ChatsExecutor( // 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) - } + 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 } @@ -1620,8 +1513,7 @@ internal class ChatsExecutor( val seedCursor = dialogsPageCursor(seed, skipArchived = !archived) val saved = warmup?.oldestListed(archived)?.let { dialogsPageOffset(it) } if (!archived && seed.isNotEmpty() && !dialogsHasMore(seed.size)) { - if (saved != null && seedCursor != null && - offsetIsOlder( + if (saved != null && seedCursor != null && offsetIsOlder( saved, seedCursor, ) @@ -1641,9 +1533,7 @@ internal class ChatsExecutor( left: 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 = { @@ -1677,31 +1567,23 @@ internal class ChatsExecutor( searchNextRate = 0 searchNextPeerId = 0L searchNextOffsetId = 0 - searchJob = - scope.launch { - if (!immediate) delay(GLOBAL_SEARCH_DEBOUNCE_MS) - if (gen != searchGen) return@launch - runSearch(trimmed, gen, reset = true) - } + searchJob = scope.launch { + if (!immediate) delay(GLOBAL_SEARCH_DEBOUNCE_MS) + if (gen != searchGen) return@launch + runSearch(trimmed, gen, reset = true) + } } 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 searchMoreJob?.cancel() - searchMoreJob = - scope.launch { - runSearch(query, gen, reset = false) - } + searchMoreJob = scope.launch { + runSearch(query, gen, reset = false) + } } private suspend fun runSearch( @@ -1723,21 +1605,19 @@ internal class ChatsExecutor( try { if (reset) { coroutineScope { - val contactsDeferred = - async { - client.contactsSearch(query, GLOBAL_SEARCH_LIMIT) - } - val globalDeferred = - async { - client.searchGlobal( - query = query, - offsetRate = 0, - offsetPeerId = PeerId(0), - offsetId = 0, - limit = GLOBAL_SEARCH_LIMIT, - folderId = folderId, - ) - } + val contactsDeferred = async { + client.contactsSearch(query, GLOBAL_SEARCH_LIMIT) + } + val globalDeferred = async { + client.searchGlobal( + query = query, + offsetRate = 0, + offsetPeerId = PeerId(0), + offsetId = 0, + limit = GLOBAL_SEARCH_LIMIT, + folderId = folderId, + ) + } val contacts = contactsDeferred.await() val global = globalDeferred.await() if (gen != searchGen) return@coroutineScope @@ -1750,25 +1630,23 @@ internal class ChatsExecutor( ) } } else { - val global = - client.searchGlobal( - query = query, - offsetRate = searchNextRate, - offsetPeerId = PeerId(searchNextPeerId), - offsetId = searchNextOffsetId, - limit = GLOBAL_SEARCH_LIMIT, - folderId = folderId, - ) + val global = client.searchGlobal( + query = query, + offsetRate = searchNextRate, + offsetPeerId = PeerId(searchNextPeerId), + offsetId = searchNextOffsetId, + limit = GLOBAL_SEARCH_LIMIT, + folderId = folderId, + ) if (gen != searchGen) return applySearchResults( gen = gen, - contacts = - Outcome.Ok( - ContactsSearch( - people = state().searchPeople, - chats = state().searchChats, - ), + contacts = Outcome.Ok( + ContactsSearch( + people = state().searchPeople, + chats = state().searchChats, ), + ), global = global, knownIds = knownIds, reset = false, @@ -1795,11 +1673,10 @@ internal class ChatsExecutor( when (contacts) { is Outcome.Ok -> { people = excludeKnownPeers(contacts.value.people, knownIds) - foundChats = - excludeKnownPeers( - contacts.value.chats, - knownIds + people.map { it.id.value }, - ) + foundChats = excludeKnownPeers( + contacts.value.chats, + knownIds + people.map { it.id.value }, + ) } is Outcome.Err -> { @@ -1812,20 +1689,18 @@ internal class ChatsExecutor( searchNextRate = page.nextRate searchNextPeerId = page.nextPeerId.value searchNextOffsetId = page.nextOffsetId - val existing = - if (reset) { - emptySet() - } else { - state().searchMessages.map { it.id }.toSet() - } + val existing = if (reset) { + emptySet() + } else { + state().searchMessages.map { it.id }.toSet() + } messages = page.messages.filter { it.id !in existing } - val hasMore = - globalSearchHasMore( - page.messages.size, - page.nextRate, - page.nextPeerId.value, - page.nextOffsetId, - ) + val hasMore = globalSearchHasMore( + page.messages.size, + page.nextRate, + page.nextPeerId.value, + page.nextOffsetId, + ) dispatch( Msg.SearchPage( people = people, @@ -1872,11 +1747,10 @@ internal class ChatsExecutor( private fun typingMessage(chatId: PeerId): Msg.Typing { val chat = state().chats.firstOrNull { it.id == chatId } val named = chat?.isGroup == true || chat?.isChannel == true - val shown = - displayedChatAction( - typingByChat[chatId.value]?.values.orEmpty(), - named = named, - ) + val shown = displayedChatAction( + typingByChat[chatId.value]?.values.orEmpty(), + named = named, + ) return Msg.Typing( chatId = chatId, typing = shown != null, 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 34fc3d33b..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 @@ -24,21 +24,16 @@ internal fun applyReadStates( 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 - ) { + 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, - unreadCount = unread, - unreadMentionsCount = mentions, - unreadReactionsCount = reactions, - ) + target[index] = chat.copy( + readInboxMaxId = readInboxMaxId, + unreadCount = unread, + unreadMentionsCount = mentions, + unreadReactionsCount = reactions, + ) } return updated ?: chats } @@ -52,20 +47,15 @@ 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) +): Chat? = chats.filter { if (archived) it.isArchiveListRow() else it.isMainListRow() } + .maxWithOrNull(chatListOrder) internal fun mergeChats( current: List, @@ -80,28 +70,24 @@ internal fun mergeChats( return@forEach } val prev = byId[incoming.id.value] - val pinnedOrder = - when { - incoming.pinned && incoming.pinnedOrder != Int.MAX_VALUE -> incoming.pinnedOrder - incoming.pinned -> prev?.pinnedOrder ?: incoming.pinnedOrder - else -> Int.MAX_VALUE - } + val pinnedOrder = when { + incoming.pinned && incoming.pinnedOrder != Int.MAX_VALUE -> incoming.pinnedOrder + incoming.pinned -> prev?.pinnedOrder ?: incoming.pinnedOrder + else -> Int.MAX_VALUE + } val live = typing[incoming.id.value] val stale = prev != null && incoming.lastMessageId > 0 && prev.lastMessageId > incoming.lastMessageId val merged = incoming.mergeLocalCache(prev) - byId[incoming.id.value] = - merged.copy( - title = preferredPeerTitle(incoming.title, prev?.title, incoming.id.value), - typing = live != null, - typingName = live?.typingName, - 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, - ) + byId[incoming.id.value] = merged.copy( + title = preferredPeerTitle(incoming.title, prev?.title, incoming.id.value), + typing = live != null, + typingName = live?.typingName, + typingAction = live?.typingAction, + pinned = if (stale) prev.pinned else incoming.pinned, + pinnedOrder = if (stale) prev.pinnedOrder else pinnedOrder, + dialogScrollMessageId = incoming.dialogScrollMessageId ?: prev?.dialogScrollMessageId, + ) } return sortChats(byId.values) } @@ -128,42 +114,40 @@ internal fun withNetworkPinOrder( 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 - } +): 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, @@ -229,12 +213,11 @@ internal fun Chat.withIncomingMessage( message: Message, accountGeneration: Long = 0, acceptedOrder: Long = 0, -): Chat = - mergeIncomingMessage( - message, - accountGeneration = accountGeneration, - acceptedOrder = acceptedOrder, - ) +): Chat = mergeIncomingMessage( + message, + accountGeneration = accountGeneration, + acceptedOrder = acceptedOrder, +) internal fun applyIncomingMessage( chats: List, 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 1f9b8037a..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 @@ -173,13 +173,12 @@ fun ChatsContent( var selectedFolderId by rememberSaveable { mutableStateOf(null) } // The list opens on the first folder; a tap or the archive outranks that default. var folderChoiceMade by rememberSaveable { mutableStateOf(false) } - val defaultFolder = - remember(folders, appearance.showAllChats) { - defaultFolderId( - folders, - appearance.showAllChats, - ) - } + val defaultFolder = remember(folders, appearance.showAllChats) { + defaultFolderId( + folders, + appearance.showAllChats, + ) + } var archiveOpen by rememberSaveable { mutableStateOf(false) } var searchOpen by rememberSaveable { mutableStateOf(false) } var folderMenu by remember { mutableStateOf(null) } @@ -204,17 +203,15 @@ fun ChatsContent( val homeFolderId = selectedFolderId.takeUnless { it == ARCHIVE_FOLDER_ID } val viewingArchive = archiveOpen || selectedFolderId == ARCHIVE_FOLDER_ID val selectFolder: (Int?) -> Unit = { id -> - val next = - onFolderChipClick( - selectedId = homeFolderId, - tappedId = id, - currentScroll = - FolderListScroll( - index = listState.firstVisibleItemIndex, - offset = listState.firstVisibleItemScrollOffset, - ), - saved = folderScroll, - ) + val next = onFolderChipClick( + selectedId = homeFolderId, + tappedId = id, + currentScroll = FolderListScroll( + index = listState.firstVisibleItemIndex, + offset = listState.firstVisibleItemScrollOffset, + ), + saved = folderScroll, + ) folderScroll = next.saved selectedFolderId = next.selectedId folderChoiceMade = true @@ -226,14 +223,12 @@ fun ChatsContent( val stableSelectFolder: (Int?) -> Unit = remember { { id -> selectFolderState.value(id) } } val openFoldersState = rememberUpdatedState(component::onOpenFolders) val stableOpenFolders: () -> Unit = remember { { openFoldersState.value() } } - val chipLongPressState = - rememberUpdatedState<(FolderChipItem) -> Unit>( - newValue = { item -> folderMenu = item }, - ) - val stableChipLongPress: (FolderChipItem) -> Unit = - remember { - { item -> chipLongPressState.value(item) } - } + val chipLongPressState = rememberUpdatedState<(FolderChipItem) -> Unit>( + newValue = { item -> folderMenu = item }, + ) + val stableChipLongPress: (FolderChipItem) -> Unit = remember { + { item -> chipLongPressState.value(item) } + } val loadMoreState = rememberUpdatedState(component::onLoadMore) val stableLoadMore: () -> Unit = remember { { loadMoreState.value() } } val retrySearchState = rememberUpdatedState(component::onRetrySearch) @@ -248,28 +243,25 @@ fun ChatsContent( val markReadState = rememberUpdatedState(component::onMarkRead) val stableMarkReadIds: (List) -> Unit = remember { { ids -> markReadState.value(ids) } } val stableMarkReadOne: (PeerId) -> Unit = remember { { id -> markReadState.value(listOf(id)) } } - val markUnreadState = - rememberUpdatedState<(PeerId) -> Unit>( - newValue = { id -> component.onMarkUnread(id, true) }, - ) + val markUnreadState = rememberUpdatedState<(PeerId) -> Unit>( + newValue = { id -> component.onMarkUnread(id, true) }, + ) val stableMarkUnread: (PeerId) -> Unit = remember { { id -> markUnreadState.value(id) } } val queryState = rememberUpdatedState(component::onQueryChanged) val stableQueryChanged: (String) -> Unit = remember { { value -> queryState.value(value) } } val stableDismissFolderMenu = remember { { folderMenu = null } } val stableDismissRowMenu = remember { { rowMenu = null } } val stableRowMenu: (Chat) -> Unit = remember { { chat -> rowMenu = chat } } - val markFolderReadState = - rememberUpdatedState<(List) -> Unit> { ids -> - folderMenu = null - markReadState.value(ids) - } + val markFolderReadState = rememberUpdatedState<(List) -> Unit> { ids -> + folderMenu = null + markReadState.value(ids) + } val stableMarkFolderRead: (List) -> Unit = remember { { ids -> markFolderReadState.value(ids) } } - val editFoldersState = - rememberUpdatedState { - folderMenu = null - openFoldersState.value() - } + val editFoldersState = rememberUpdatedState { + folderMenu = null + openFoldersState.value() + } val stableEditFolders: () -> Unit = remember { { editFoldersState.value() } } // Paging follows the visible folder: a rule-based folder needs its own dialog stream. LaunchedEffect(homeFolderId, viewingArchive) { @@ -282,11 +274,7 @@ fun ChatsContent( 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) + (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,13 +298,12 @@ fun ChatsContent( val openArchiveState = rememberUpdatedState(openArchive) val stableOpenArchive: () -> Unit = remember { { openArchiveState.value() } } DisposableEffect(component, viewingArchive) { - val callback = - BackCallback(isEnabled = viewingArchive) { - searchOpen = false - component.onQueryChanged("") - archiveOpen = false - if (selectedFolderId == ARCHIVE_FOLDER_ID) selectedFolderId = null - } + val callback = BackCallback(isEnabled = viewingArchive) { + searchOpen = false + component.onQueryChanged("") + archiveOpen = false + if (selectedFolderId == ARCHIVE_FOLDER_ID) selectedFolderId = null + } component.backHandler.register(callback) onDispose { component.backHandler.unregister(callback) } } @@ -325,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) @@ -348,20 +338,19 @@ fun ChatsContent( val searchRetryLabel = stringResource(R.string.chats_search_retry) val foldersAtBottom = appearance.foldersAtBottom val showAllChats = appearance.showAllChats - val chipItems = - remember(state.chats, folders, showMutedCounter, allChatsLabel, showAllChats) { - perfSpan("chips") { - FolderChips( - folderChipItems( - chats = state.chats, - folders = folders, - allChatsLabel = allChatsLabel, - showMutedCounter = showMutedCounter, - showAllChats = showAllChats, - ), - ) - } + val chipItems = remember(state.chats, folders, showMutedCounter, allChatsLabel, showAllChats) { + perfSpan("chips") { + FolderChips( + folderChipItems( + chats = state.chats, + folders = folders, + allChatsLabel = allChatsLabel, + showMutedCounter = showMutedCounter, + showAllChats = showAllChats, + ), + ) } + } val showFolderChips = remember(chipItems) { showsFolderChipRow(chipItems.items) } val allChats = remember { ChatListSnapshot() } val shownChats = remember { ChatListSnapshot() } @@ -375,26 +364,23 @@ fun ChatsContent( ), ) } - val archivedSlice = - remember(state.chats) { - perfSpan("archiveSlice") { - visibleChats(state.chats, emptyList(), ARCHIVE_FOLDER_ID) - } + val archivedSlice = remember(state.chats) { + perfSpan("archiveSlice") { + visibleChats(state.chats, emptyList(), ARCHIVE_FOLDER_ID) } + } perfSpan("archivedChats") { archivedChats.replace(filterChats(archivedSlice, state.query)) } - val archivedPreview = - remember(archivedSlice) { - folderUnreadBadge(archivedSlice) - } - val archivedTitles = - remember(archivedSlice, texts.untitled) { - archivePreviewTitles( - chats = archivedSlice, - untitledLabel = texts.untitled, - ) - } + val archivedPreview = remember(archivedSlice) { + folderUnreadBadge(archivedSlice) + } + val archivedTitles = remember(archivedSlice, texts.untitled) { + archivePreviewTitles( + chats = archivedSlice, + untitledLabel = texts.untitled, + ) + } // Counted for visibility only: the header never prints totals. val archivedCount = archivedSlice.size val layoutDirection = LocalLayoutDirection.current @@ -416,72 +402,63 @@ 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") - } + 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 } + } else { + Modifier + } val atTop by remember(paneListState) { derivedStateOf { - paneListState.firstVisibleItemIndex == 0 && - paneListState.firstVisibleItemScrollOffset <= AtTopTolerance + paneListState.firstVisibleItemIndex == 0 && paneListState.firstVisibleItemScrollOffset <= AtTopTolerance } } - val rawSync = - when { - paneEmpty && (state.loading || state.syncing) -> AppSyncStatus.Connecting - state.syncing || (state.loading && !paneEmpty) -> AppSyncStatus.Syncing - state.loadingMore -> AppSyncStatus.LoadingMore - else -> AppSyncStatus.Hidden - } - val syncStatus = - rememberDebouncedSync( - status = rawSync, - immediate = rawSync == AppSyncStatus.Connecting || rawSync == AppSyncStatus.Syncing, - ) + val rawSync = when { + paneEmpty && (state.loading || state.syncing) -> AppSyncStatus.Connecting + state.syncing || (state.loading && !paneEmpty) -> AppSyncStatus.Syncing + state.loadingMore -> AppSyncStatus.LoadingMore + else -> AppSyncStatus.Hidden + } + val syncStatus = rememberDebouncedSync( + status = rawSync, + immediate = rawSync == AppSyncStatus.Connecting || rawSync == AppSyncStatus.Syncing, + ) val unreadChats = paneChats.unmutedUnread - val overlayTitle = - when (syncStatus) { - AppSyncStatus.Connecting, AppSyncStatus.Syncing -> syncStatus.uiLabel() - else -> null + val overlayTitle = when (syncStatus) { + AppSyncStatus.Connecting, AppSyncStatus.Syncing -> syncStatus.uiLabel() + else -> null + } + val subtitle = when { + overlayTitle != null -> { + null } - val subtitle = - when { - overlayTitle != null -> { - null - } - - state.error?.kind == TelegramError.Kind.Network -> { - stringResource(R.string.chats_waiting_network) - } - unreadChats > 0 -> { - pluralStringResource(R.plurals.chats_unread_chats, unreadChats, unreadChats) - } + state.error?.kind == TelegramError.Kind.Network -> { + stringResource(R.string.chats_waiting_network) + } - else -> { - null - } + unreadChats > 0 -> { + pluralStringResource(R.plurals.chats_unread_chats, unreadChats, unreadChats) } - val folderTitle = - when { - archive -> archiveLabel - homeFolderId == null -> allChatsLabel - else -> folders.firstOrNull { it.id == homeFolderId }?.title ?: allChatsLabel + + else -> { + null } + } + val folderTitle = when { + archive -> archiveLabel + homeFolderId == null -> allChatsLabel + else -> folders.firstOrNull { it.id == homeFolderId }?.title ?: allChatsLabel + } val markAllIds = remember(state.chats, archive) { unreadChatIds(state.chats, archive) } Scaffold( @@ -514,10 +491,9 @@ fun ChatsContent( ) { Icon( imageVector = if (searchOpen) Icons.Outlined.Close else Icons.Outlined.Search, - contentDescription = - stringResource( - if (searchOpen) R.string.chats_search_close else R.string.chats_search, - ), + contentDescription = stringResource( + if (searchOpen) R.string.chats_search_close else R.string.chats_search, + ), ) } }, @@ -532,26 +508,24 @@ fun ChatsContent( overlayTitle = overlayTitle, archive = archive, selfTitle = state.self?.title ?: stringResource(R.string.chats_profile), - selfAvatar = - rememberSelfAvatar( - self = state.self, - mediaRepository = component.mediaRepository, + selfAvatar = rememberSelfAvatar( + self = state.self, + mediaRepository = component.mediaRepository, + ), + selfPreview = rememberStrippedPreview( + Triple( + component.mediaRepository, + state.self?.id, + state.self?.avatarCacheKey, ), - selfPreview = - rememberStrippedPreview( - Triple( - component.mediaRepository, - state.self?.id, - state.self?.avatarCacheKey + ) { + state.self?.id?.let { + component.mediaRepository?.peerInlineThumbJpeg( + it, + state.self?.avatarCacheKey, ) - ) { - state.self?.id?.let { - component.mediaRepository?.peerInlineThumbJpeg( - it, - state.self?.avatarCacheKey - ) - } - }, + } + }, selfOnline = selfOnline, mediaRepository = component.mediaRepository, searchOpen = searchOpen, @@ -568,18 +542,14 @@ 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 - }, + 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), @@ -594,13 +564,12 @@ fun ChatsContent( }, ) { inner -> Column( - modifier = - Modifier - .fillMaxSize() - .padding( - top = inner.calculateTopPadding(), - bottom = inner.calculateBottomPadding(), - ), + modifier = Modifier + .fillMaxSize() + .padding( + top = inner.calculateTopPadding(), + bottom = inner.calculateBottomPadding(), + ), ) { AnimatedVisibility( visible = searchOpen, @@ -620,18 +589,16 @@ fun ChatsContent( AppStatusBanner( sync = syncStatus, error = if (state.query.isNotBlank()) state.searchError else state.error, - onRetry = - if (state.query.isNotBlank()) { - component::onRetrySearch - } else { - component::onRefresh - }, + onRetry = if (state.query.isNotBlank()) { + component::onRetrySearch + } else { + component::onRefresh + }, ) Column( - modifier = - Modifier - .weight(1f) - .fillMaxSize(), + modifier = Modifier + .weight(1f) + .fillMaxSize(), ) { if (!archive && !searchOpen && !foldersAtBottom && showFolderChips) { Box { @@ -662,12 +629,11 @@ fun ChatsContent( } } Box( - modifier = - Modifier - .weight(1f) - .fillMaxSize(), + modifier = Modifier + .weight(1f) + .fillMaxSize(), ) { - AnimatedVisibility( + androidx.compose.animation.AnimatedVisibility( visible = state.loading && paneEmpty && !folderMoving, modifier = Modifier.fillMaxSize(), enter = fadeIn(animationSpec = tween(180)), @@ -752,29 +718,26 @@ fun ChatsContent( motionEnabled = listMotionEnabled(), onMoving = { folderMoving = it }, ) { folderId, isPrimary -> - val pageChats = - if (folderId == homeFolderId) { - shownChats - } else { - remember { ChatListSnapshot() }.also { snapshot -> - snapshot.replace( - filterChats( - visibleChats(state.chats, folders, folderId), - state.query, - ), - ) - } + val pageChats = if (folderId == homeFolderId) { + shownChats + } else { + remember { ChatListSnapshot() }.also { snapshot -> + snapshot.replace( + filterChats( + visibleChats(state.chats, folders, folderId), + state.query, + ), + ) } - val restored = - clampFolderScroll( - folderScroll[folderScrollKey(folderId)], - pageChats.size, - ) - val pageListState = - rememberLazyListState( - initialFirstVisibleItemIndex = restored.index, - initialFirstVisibleItemScrollOffset = restored.offset, - ) + } + val restored = clampFolderScroll( + folderScroll[folderScrollKey(folderId)], + pageChats.size, + ) + val pageListState = rememberLazyListState( + initialFirstVisibleItemIndex = restored.index, + initialFirstVisibleItemScrollOffset = restored.offset, + ) SideEffect { if (isPrimary) primaryListState = pageListState } @@ -792,9 +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, @@ -926,12 +887,11 @@ private fun FloatingFolderBar( val miniPlayerPad = if (MediaPlaybackHolder.session(context).showsMiniPlayer()) 72.dp else 0.dp val barColor = MaterialTheme.colorScheme.surfaceContainerHigh Surface( - modifier = - modifier - .fillMaxWidth() - .wrapContentHeight() - .navigationBarsPadding() - .padding(start = 12.dp, end = 12.dp, bottom = 12.dp + miniPlayerPad), + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .navigationBarsPadding() + .padding(start = 12.dp, end = 12.dp, bottom = 12.dp + miniPlayerPad), shape = MaterialTheme.shapes.extraLarge, color = barColor, tonalElevation = 3.dp, @@ -977,16 +937,13 @@ internal fun FolderChipMenu( onMarkRead: (List) -> Unit, onEditFolders: () -> Unit, ) { - val unread = - remember(folderId, chats, folders) { - if (folderId == null) { - emptyList() - } else { - visibleChats(chats, folders, folderId) - .filter { it.unreadCount > 0 } - .map { it.id } - } + val unread = remember(folderId, chats, folders) { + if (folderId == null) { + emptyList() + } else { + visibleChats(chats, folders, folderId).filter { it.unreadCount > 0 }.map { it.id } } + } AppMenuSurface { AppMenuGroup { AppMenuItem( @@ -1009,19 +966,18 @@ private fun rememberSelfAvatar( self: Profile?, mediaRepository: MediaRepository?, ) = rememberEnsuredFile( - generation = - rememberCacheGeneration( - remember(mediaRepository, self?.id, self?.avatarCacheKey) { - self?.id?.let { - mediaRepository?.cacheGeneration( - peerAvatarCacheKey( - it, - self.avatarCacheKey, - ), - ) - } - }, - ), + generation = rememberCacheGeneration( + remember(mediaRepository, self?.id, self?.avatarCacheKey) { + self?.id?.let { + mediaRepository?.cacheGeneration( + peerAvatarCacheKey( + it, + self.avatarCacheKey, + ), + ) + } + }, + ), identity = self?.id?.value to self?.avatarCacheKey, resolve = { self?.avatarCacheKey?.let { mediaRepository?.cachedFile(it) } @@ -1029,14 +985,11 @@ private fun rememberSelfAvatar( 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 } @@ -1080,22 +1033,20 @@ private fun filterChats( /** Scroll slop that still counts as "the list is at the top". */ private const val AtTopTolerance = 8 -private val FolderScrollSaver = - listSaver, Int>( - save = { entries -> - entries.flatMap { (id, scroll) -> - listOf( - id, - scroll.index, - scroll.offset, - ) - } - }, - restore = { values -> - values.chunked(3) - .associate { (id, index, offset) -> id to FolderListScroll(index, offset) } - }, - ) +private val FolderScrollSaver = listSaver, Int>( + save = { entries -> + entries.flatMap { (id, scroll) -> + listOf( + id, + scroll.index, + scroll.offset, + ) + } + }, + restore = { values -> + values.chunked(3).associate { (id, index, offset) -> id to FolderListScroll(index, offset) } + }, +) @Composable internal fun FolderEmptyState( @@ -1131,47 +1082,42 @@ internal fun FolderEmptyState( animationSpec = if (motion) enterDp else snap(), label = "emptyTextShift", ) - val icon = - when { - archive -> Icons.Outlined.Inventory2 - query.isNotBlank() -> Icons.Outlined.SearchOff - else -> Icons.Outlined.Forum - } - val title = - when { - selectingRecipient && query.isBlank() -> stringResource(R.string.chats_share_empty) - archive -> stringResource(R.string.chats_archive_empty) - query.isNotBlank() -> stringResource(R.string.chats_search_empty) - filtered -> stringResource(R.string.chats_folder_empty) - else -> stringResource(R.string.chats_empty) - } - val body = - when { - selectingRecipient && query.isBlank() -> stringResource(R.string.chats_share_empty_body) - archive -> stringResource(R.string.chats_archive_empty_body) - query.isNotBlank() -> stringResource(R.string.chats_search_empty_body, query) - filtered -> stringResource(R.string.chats_folder_empty_body) - else -> stringResource(R.string.chats_empty_body) - } + val icon = when { + archive -> Icons.Outlined.Inventory2 + query.isNotBlank() -> Icons.Outlined.SearchOff + else -> Icons.Outlined.Forum + } + val title = when { + selectingRecipient && query.isBlank() -> stringResource(R.string.chats_share_empty) + archive -> stringResource(R.string.chats_archive_empty) + query.isNotBlank() -> stringResource(R.string.chats_search_empty) + filtered -> stringResource(R.string.chats_folder_empty) + else -> stringResource(R.string.chats_empty) + } + val body = when { + selectingRecipient && query.isBlank() -> stringResource(R.string.chats_share_empty_body) + archive -> stringResource(R.string.chats_archive_empty_body) + query.isNotBlank() -> stringResource(R.string.chats_search_empty_body, query) + filtered -> stringResource(R.string.chats_folder_empty_body) + else -> stringResource(R.string.chats_empty_body) + } Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 24.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Surface( color = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, shape = RoundedCornerShape(32.dp), - modifier = - Modifier - .size(96.dp) - .graphicsLayer { - scaleX = badgeScale - scaleY = badgeScale - alpha = badgeAlpha - }, + modifier = Modifier + .size(96.dp) + .graphicsLayer { + scaleX = badgeScale + scaleY = badgeScale + alpha = badgeAlpha + }, ) { Box(contentAlignment = Alignment.Center) { Icon(icon, null, Modifier.size(40.dp)) @@ -1179,11 +1125,10 @@ internal fun FolderEmptyState( } Spacer(Modifier.height(20.dp)) Column( - modifier = - Modifier.graphicsLayer { - alpha = textAlpha - translationY = textShift.toPx() - }, + modifier = Modifier.graphicsLayer { + alpha = textAlpha + translationY = textShift.toPx() + }, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( @@ -1221,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/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/store/DialogExecutor.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt index 648a58d3a..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 @@ -62,12 +61,15 @@ private fun presenceLabelChanged( ): Boolean { val now = System.currentTimeMillis() return LastSeen.resolve(previousStatus, previousAt, now) != LastSeen.resolve( - nextStatus, nextAt, now + nextStatus, + nextAt, + 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, @@ -103,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 @@ -127,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 @@ -145,54 +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() + } } } @@ -202,36 +222,46 @@ internal class DialogExecutor( if (deleted.isNotEmpty()) { dispatch(Msg.MessagesDeleted(deleted)) rememberPinned() - if (!update.persisted) 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)) + } } } @@ -259,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, + ) + } } } @@ -311,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)) @@ -340,7 +373,8 @@ internal class DialogExecutor( dispatch(Msg.PhotoCacheKey(chat.photoCacheKey)) } val rights = rightsMsg(chat) - val stale = chat.lastMessageId > 0 && sourceLastMessageId > chat.lastMessageId + val stale = + chat.lastMessageId > 0 && sourceLastMessageId > chat.lastMessageId if (!stale && !(muteOverride && !chat.muteOverride)) { rawMuted = chat.muted muteOverride = chat.muteOverride @@ -356,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 -> { @@ -387,7 +426,9 @@ internal class DialogExecutor( } } - else -> Unit + else -> { + Unit + } } }.launchIn(scope) refresh() @@ -397,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) { @@ -426,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() @@ -455,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)) @@ -504,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() @@ -559,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( @@ -574,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)) } @@ -594,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)) @@ -620,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) @@ -632,102 +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) + } + + 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.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() + DialogStore.Intent.ReadOnlyBar -> { + readOnlyBar() + } } } @@ -783,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)) } @@ -823,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) } @@ -840,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)) @@ -863,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) } @@ -885,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) @@ -906,7 +1206,10 @@ internal class DialogExecutor( 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 @@ -946,13 +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 + result.value.size, + HISTORY_FIRST_LIMIT, ) || cached.size > result.value.size || cachedOlderTail.isNotEmpty(), ), ) @@ -1000,7 +1307,7 @@ internal class DialogExecutor( 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) @@ -1021,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)) @@ -1042,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)) @@ -1066,7 +1376,9 @@ internal class DialogExecutor( if (state().isGroup) loadAdminTags() } - is Outcome.Err -> Unit + is Outcome.Err -> { + Unit + } } } } @@ -1078,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 + } } } } @@ -1127,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() @@ -1178,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)) + } } } } @@ -1206,7 +1525,9 @@ internal class DialogExecutor( } } - is Outcome.Err -> dispatch(Msg.Error(result.telegramError)) + is Outcome.Err -> { + dispatch(Msg.Error(result.telegramError)) + } } } } @@ -1221,7 +1542,9 @@ internal class DialogExecutor( } } - is Outcome.Err -> dispatch(Msg.Error(result.telegramError)) + is Outcome.Err -> { + dispatch(Msg.Error(result.telegramError)) + } } } } @@ -1295,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, _) -> @@ -1305,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 d3b08d10c..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) } @@ -324,9 +324,7 @@ internal fun DialogExecutor.loadOlder(prefetch: Boolean = false) { emit(Msg.AppendOlder(slice.rows)) 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)) @@ -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/DialogTopBar.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogTopBar.kt index c46726eb1..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 @@ -89,12 +89,11 @@ internal fun DialogTopBar( windowInsets = WindowInsets.statusBars, title = { Text( - text = - pluralStringResource( - R.plurals.dialog_messages_selected, - selectedMessageCount, - selectedMessageCount, - ), + text = pluralStringResource( + R.plurals.dialog_messages_selected, + selectedMessageCount, + selectedMessageCount, + ), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -126,10 +125,9 @@ internal fun DialogTopBar( ) } }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), ) return } @@ -154,43 +152,35 @@ internal fun DialogTopBar( modifier = Modifier.clickable(onClick = component::onOpenProfile), ) { val cacheGeneration = - component.mediaRepository - ?.cacheGeneration - ?.collectAsState() - ?.value + component.mediaRepository?.cacheGeneration?.collectAsState()?.value ?: 0L - val chatAvatar = - rememberEnsuredFile( - generation = cacheGeneration, - identity = state.chatId.value to state.photoCacheKey, - resolve = { - state.photoCacheKey?.let { - component.mediaRepository?.cachedFile( - it - ) - } - }, - ensure = { - val repo = - component.mediaRepository ?: return@rememberEnsuredFile null - val key = - peerAvatarCacheKey( - state.chatId, - state.photoCacheKey, - ) - when ( - val result = - repo.ensureLocalAvatar( - state.chatId, - key, - priority = MediaPriority.VISIBLE, - ) - ) { - is Outcome.Ok -> result.value - is Outcome.Err -> null - } - }, - ) + val chatAvatar = rememberEnsuredFile( + generation = cacheGeneration, + identity = state.chatId.value to state.photoCacheKey, + resolve = { + state.photoCacheKey?.let { + component.mediaRepository?.cachedFile( + it, + ) + } + }, + ensure = { + val repo = + component.mediaRepository ?: return@rememberEnsuredFile null + val key = peerAvatarCacheKey( + state.chatId, + state.photoCacheKey, + ) + when (val result = repo.ensureLocalAvatar( + state.chatId, + key, + priority = MediaPriority.VISIBLE, + )) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + }, + ) if (state.inForumTopic) { TopicIcon( org.monogram.core.models.ForumTopic( @@ -205,19 +195,18 @@ internal fun DialogTopBar( title = title, size = 38.dp, imageFile = chatAvatar, - previewBytes = - rememberStrippedPreview( - Triple( - mediaRepository, - state.chatId, - state.photoCacheKey - ) - ) { - component.mediaRepository?.peerInlineThumbJpeg( - state.chatId, - state.photoCacheKey - ) - }, + previewBytes = rememberStrippedPreview( + Triple( + mediaRepository, + state.chatId, + state.photoCacheKey, + ), + ) { + component.mediaRepository?.peerInlineThumbJpeg( + state.chatId, + state.photoCacheKey, + ) + }, ) } Column(modifier = Modifier.weight(1f)) { @@ -247,19 +236,17 @@ internal fun DialogTopBar( status = state.peerStatus, statusAt = state.peerStatusAt, onExpired = component::onRefreshPresence, - typing = - displayedChatAction( - state.typingUsers.values, - named = state.isGroup || state.isChannel, - ), + typing = displayedChatAction( + state.typingUsers.values, + named = state.isGroup || state.isChannel, + ), syncLabel = syncStatus.uiLabel(), - stats = - chatStatsSubtitle( - isChannel = state.isChannel, - isGroup = state.isGroup, - members = state.membersCount, - online = state.onlineCount, - ), + stats = chatStatsSubtitle( + isChannel = state.isChannel, + isGroup = state.isGroup, + members = state.membersCount, + online = state.onlineCount, + ), channelLabel = stringResource(R.string.dialog_channel), groupLabel = stringResource(R.string.dialog_group), isChannel = state.isChannel, @@ -300,17 +287,15 @@ internal fun DialogTopBar( ) { Icon( imageVector = if (showSearch) Icons.Outlined.Close else Icons.Outlined.Search, - contentDescription = - stringResource( - if (showSearch) R.string.dialog_search_close else R.string.dialog_search, - ), + contentDescription = stringResource( + if (showSearch) R.string.dialog_search_close else R.string.dialog_search, + ), ) } }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), ) DialogDownloadProgressBar( downloads = userDownloads, @@ -349,20 +334,15 @@ private fun DialogStatusLine( val presenceNow = rememberPeerStatusNow(presenceStatus, presenceAt) OnlineLeaseExpiry(presenceStatus, presenceAt, onExpired) val fallback = - syncLabel - ?: peerStatusLabel(presenceStatus, presenceAt, presenceNow) - ?: stats - ?: when { - isChannel -> channelLabel - isGroup -> groupLabel - else -> null - } + syncLabel ?: peerStatusLabel(presenceStatus, presenceAt, presenceNow) ?: stats ?: when { + isChannel -> channelLabel + isGroup -> groupLabel + else -> null + } 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 { @@ -394,12 +374,11 @@ private fun DialogStatusLine( Text( text = statusText, style = MaterialTheme.typography.labelMedium, - color = - if (syncLabel != null) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + color = if (syncLabel != null) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, maxLines = 1, ) } 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 4cf3c88fb..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 @@ -44,18 +44,17 @@ fun ForwardPickerSheet( onSelect: (PeerId) -> Unit, onDismiss: () -> Unit, ) { - val shown = - remember(targets, query) { - val q = query.trim() - if (q.isEmpty()) { - targets - } else { - targets.filter { - it.title.contains(q, ignoreCase = true) || - it.lastMessagePreview.orEmpty().contains(q, ignoreCase = true) - } + val shown = remember(targets, query) { + val q = query.trim() + if (q.isEmpty()) { + targets + } else { + targets.filter { + it.title.contains(q, ignoreCase = true) || it.lastMessagePreview.orEmpty() + .contains(q, ignoreCase = true) } } + } AppModalSheet( onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surfaceContainerLow, @@ -67,10 +66,9 @@ fun ForwardPickerSheet( status = stringResource(R.string.dialog_forwarding), ) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), ) { Text( text = stringResource(R.string.dialog_forward_to), @@ -80,10 +78,9 @@ fun ForwardPickerSheet( MonogramBusyField( value = query, onValueChange = onQuery, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), busy = forwarding, singleLine = true, shape = MaterialTheme.shapes.large, @@ -104,7 +101,8 @@ fun ForwardPickerSheet( items( shown, key = { it.id.value }, - contentType = { "forward-chat" }) { chat -> + contentType = { "forward-chat" }, + ) { chat -> ForwardChatRow( chat = chat, mediaRepository = mediaRepository, @@ -128,27 +126,25 @@ private fun ForwardChatRow( ) { val cacheGeneration = mediaRepository?.cacheGeneration?.collectAsState()?.value ?: 0L val key = peerAvatarCacheKey(chat.id, chat.photoCacheKey) - val avatarFile = - rememberEnsuredFile( - generation = cacheGeneration, - identity = chat.id.value to key, - resolve = { - mediaRepository?.cachedFile(key) - }, - ensure = { - val repo = mediaRepository ?: return@rememberEnsuredFile null - when (val result = repo.ensureLocalAvatar(chat.id, key)) { - is Outcome.Ok -> result.value - is Outcome.Err -> null - } - }, - ) + val avatarFile = rememberEnsuredFile( + generation = cacheGeneration, + identity = chat.id.value to key, + resolve = { + mediaRepository?.cachedFile(key) + }, + ensure = { + val repo = mediaRepository ?: return@rememberEnsuredFile null + when (val result = repo.ensureLocalAvatar(chat.id, key)) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + }, + ) Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 16.dp, vertical = 10.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -156,10 +152,9 @@ private fun ForwardChatRow( title = chat.title, size = 44.dp, imageFile = avatarFile, - previewBytes = - rememberStrippedPreview(Triple(mediaRepository, chat.id, key)) { - mediaRepository?.peerInlineThumbJpeg(chat.id, key) - }, + previewBytes = rememberStrippedPreview(Triple(mediaRepository, chat.id, key)) { + mediaRepository?.peerInlineThumbJpeg(chat.id, key) + }, ) Column(modifier = Modifier.weight(1f)) { Text( 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 15148912d..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 @@ -154,21 +154,19 @@ fun MessageBubble( contentAlignment = Alignment.Center, ) { Text( - text = - localizedServiceMessage( - raw = message.text.orEmpty(), - outgoing = outgoing, - ), - modifier = - Modifier - .then( - if (onQuoteClick != null && message.replyToMsgId != null) { - Modifier.clickable(onClick = onQuoteClick) - } else { - Modifier - }, - ) - .padding(horizontal = 24.dp, vertical = 8.dp), + text = localizedServiceMessage( + raw = message.text.orEmpty(), + outgoing = outgoing, + ), + modifier = Modifier + .then( + if (onQuoteClick != null && message.replyToMsgId != null) { + Modifier.clickable(onClick = onQuoteClick) + } else { + Modifier + }, + ) + .padding(horizontal = 24.dp, vertical = 8.dp), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -181,10 +179,9 @@ fun MessageBubble( var appearancePlayed by rememberSaveable(appearanceKey) { mutableStateOf(false) } - val appear = - remember(appearanceKey) { - Animatable(if (!appearancePlayed && message.shouldPlayAppear()) 0f else 1f) - } + val appear = remember(appearanceKey) { + Animatable(if (!appearancePlayed && message.shouldPlayAppear()) 0f else 1f) + } LaunchedEffect(appearanceKey) { appearancePlayed = true if (appear.value < 1f) { @@ -199,23 +196,20 @@ fun MessageBubble( val hasSpoiler = renderedBlocks.containsSpoilers() val senderTitle = sender?.title?.takeIf { it.isNotBlank() } ?: message.senderName?.takeIf { it.isNotBlank() } - val quote = - if (quoted?.mediaKind == "service") { - // Service payloads are deliberately compact on the wire; never expose - // their unit separators in a reply preview. - localizedServiceMessage( - raw = quoted.text.orEmpty(), - outgoing = quoted.outgoing, - ) - } else { - message.replyQuote?.takeIf { it.isNotBlank() } - ?: quoted?.text?.takeIf { it.isNotBlank() } - } + val quote = if (quoted?.mediaKind == "service") { + // Service payloads are deliberately compact on the wire; never expose + // their unit separators in a reply preview. + localizedServiceMessage( + raw = quoted.text.orEmpty(), + outgoing = quoted.outgoing, + ) + } else { + message.replyQuote?.takeIf { it.isNotBlank() } ?: quoted?.text?.takeIf { it.isNotBlank() } + } val showReplyQuote = !hideTopicRootReply && (message.replyToMsgId != null || !quote.isNullOrBlank()) val bubbleHeader = - (showSender && senderTitle != null) || !message.fwdFrom.isNullOrBlank() || message.fwdFromId != null || message.fwdDate != null || - !message.viaBot.isNullOrBlank() + (showSender && senderTitle != null) || !message.fwdFrom.isNullOrBlank() || message.fwdFromId != null || message.fwdDate != null || !message.viaBot.isNullOrBlank() val mediaCaption = shouldShowMessageCaption(message.mediaKind, message.text, message.fileName) val isVisual = isVisualAlbum(album) val isNonVisual = isNonVisualAlbum(album) @@ -230,43 +224,38 @@ fun MessageBubble( val edgeMedia = edgeVisualMedia && !mediaOnlyBubble val mediaBleedTop = edgeMedia && !bubbleHeader && !showReplyQuote val mediaBleedBottom = shouldBleedMediaBottom(edgeMedia, mediaCaption, hasComments) - val mediaOverlayMeta = - shouldOverlayMediaMeta( - stickerOnly = stickerOnly, - edgeVisualMedia = edgeVisualMedia, - mediaCaption = mediaCaption, - ) + val mediaOverlayMeta = shouldOverlayMediaMeta( + stickerOnly = stickerOnly, + edgeVisualMedia = edgeVisualMedia, + mediaCaption = mediaCaption, + ) // Picture bubbles let the media own the horizontal edges, so the text around it is // padded on its own instead of the whole bubble. val edgeContentPad = if (edgeVisualMedia) BUBBLE_CONTENT_PAD else 0.dp // A caption never widens a picture bubble: the bubble hugs the picture and the // caption wraps, so the picture stays flush with both edges. - val mediaFrameWidth = - if (singleVisualMedia) { - bubbleEdgeMediaDisplaySize( - message.mediaKind, - message.mediaWidth, - message.mediaHeight, - ).first.dp - } else { - null - } - val shape = - remember(outgoing, joinsMessageAbove, joinsMessageBelow) { - messageShape(outgoing, joinsMessageAbove, joinsMessageBelow) - } - val container = - if (outgoing) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - } - val onContainer = - if (outgoing) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - } + val mediaFrameWidth = if (singleVisualMedia) { + bubbleEdgeMediaDisplaySize( + message.mediaKind, + message.mediaWidth, + message.mediaHeight, + ).first.dp + } else { + null + } + val shape = remember(outgoing, joinsMessageAbove, joinsMessageBelow) { + messageShape(outgoing, joinsMessageAbove, joinsMessageBelow) + } + val container = if (outgoing) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val onContainer = if (outgoing) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = if (outgoing) Arrangement.End else Arrangement.Start, @@ -274,141 +263,132 @@ fun MessageBubble( ) { 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 - } - }, - ) + 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( - Triple( - mediaRepository, - sender?.id, - sender?.avatarCacheKey + previewBytes = rememberStrippedPreview( + Triple( + mediaRepository, + sender?.id, + sender?.avatarCacheKey, + ), + ) { + sender?.id?.let { + mediaRepository?.peerInlineThumbJpeg( + it, + sender.avatarCacheKey, ) - ) { - sender?.id?.let { - mediaRepository?.peerInlineThumbJpeg( - it, - sender?.avatarCacheKey - ) - } - }, - modifier = - Modifier - .padding(end = 6.dp, bottom = 2.dp) - .then( - if (onOpenSender != null) { - Modifier.clickable(onClick = onOpenSender) - } else { - Modifier - }, - ), + } + }, + modifier = Modifier + .padding(end = 6.dp, bottom = 2.dp) + .then( + if (onOpenSender != null) { + Modifier.clickable(onClick = onOpenSender) + } else { + Modifier + }, + ), ) } else if (!outgoing && reserveAvatarGutter) { Spacer(Modifier.size(width = 34.dp, height = 28.dp)) } - val centerMedia = - !stickerOnly && - when (message.mediaKind) { - "photo", "video", "gif" -> true - else -> false - } + val centerMedia = !stickerOnly && when (message.mediaKind) { + "photo", "video", "gif" -> true + else -> false + } Column( - modifier = - Modifier - .widthIn(max = BUBBLE_MAX_WIDTH_DP.dp) - .wrapContentWidth( - align = if (outgoing) Alignment.End else Alignment.Start, - ) - .width(IntrinsicSize.Max) - .graphicsLayer { - val t = appear.value - translationY = (1f - t) * (if (outgoing) 6f else 10f) - }, + modifier = Modifier + .widthIn(max = BUBBLE_MAX_WIDTH_DP.dp) + .wrapContentWidth( + align = if (outgoing) Alignment.End else Alignment.Start, + ) + .width(IntrinsicSize.Max) + .graphicsLayer { + val t = appear.value + translationY = (1f - t) * (if (outgoing) 6f else 10f) + }, horizontalAlignment = if (outgoing) Alignment.End else Alignment.Start, ) { Column( - modifier = - Modifier - .then(if (stickerOnly) Modifier else Modifier.clip(shape)) - .then(mediaFrameWidth?.let { Modifier.width(it) } ?: Modifier) - .then( - if (stickerOnly || mediaOnlyBubble) { - Modifier - } else { - Modifier.background(container) - }, - ) - .then( - if (!selectable && !passClicks && onOpenMenu != null) { - val openMenu = rememberUpdatedState(onOpenMenu) - val hold = rememberUpdatedState(onLongPress) - Modifier - .onGloballyPositioned { bubblePosition = it.positionInWindow() } - .pointerInput(hasSpoiler) { - if (hasSpoiler) { - menuOrHold( - onTap = { - spoilersVisible = !spoilersVisible - openMenu.value.invoke(bubblePosition) - }, - onLongPress = { - hold.value?.invoke() ?: openMenu.value.invoke( - bubblePosition, - ) - }, - ) - } else { - menuOrHold( - onTap = { openMenu.value.invoke(bubblePosition + it) }, - onLongPress = { - hold.value?.invoke() ?: openMenu.value.invoke( - bubblePosition, - ) - }, - ) - } + modifier = Modifier + .then(if (stickerOnly) Modifier else Modifier.clip(shape)) + .then(mediaFrameWidth?.let { Modifier.width(it) } ?: Modifier) + .then( + if (stickerOnly || mediaOnlyBubble) { + Modifier + } else { + Modifier.background(container) + }, + ) + .then( + if (!selectable && !passClicks && onOpenMenu != null) { + val openMenu = rememberUpdatedState(onOpenMenu) + val hold = rememberUpdatedState(onLongPress) + Modifier.onGloballyPositioned { bubblePosition = it.positionInWindow() } + .pointerInput(hasSpoiler) { + if (hasSpoiler) { + menuOrHold( + onTap = { + spoilersVisible = !spoilersVisible + openMenu.value.invoke(bubblePosition) + }, + onLongPress = { + hold.value?.invoke() ?: openMenu.value.invoke( + bubblePosition, + ) + }, + ) + } else { + menuOrHold( + onTap = { openMenu.value.invoke(bubblePosition + it) }, + onLongPress = { + hold.value?.invoke() ?: openMenu.value.invoke( + bubblePosition, + ) + }, + ) + } + } + } else if (!selectable && !passClicks && (onLongPress != null || hasSpoiler)) { + Modifier.combinedClickable( + onClick = { + if (hasSpoiler) { + spoilersVisible = !spoilersVisible } - } else if (!selectable && !passClicks && (onLongPress != null || hasSpoiler)) { - Modifier.combinedClickable( - onClick = { - if (hasSpoiler) { - spoilersVisible = !spoilersVisible - } - }, - onLongClick = { onLongPress?.invoke() }, - onClickLabel = null, - ) - } else { - Modifier - }, - ) - .padding( - horizontal = if (stickerOnly || edgeVisualMedia) 0.dp else BUBBLE_CONTENT_PAD, - vertical = - when { - stickerOnly -> 2.dp - mediaOnlyBubble -> 0.dp - else -> BUBBLE_CONTENT_VPAD }, - ), + onLongClick = { onLongPress?.invoke() }, + onClickLabel = null, + ) + } else { + Modifier + }, + ) + .padding( + horizontal = if (stickerOnly || edgeVisualMedia) 0.dp else BUBBLE_CONTENT_PAD, + vertical = when { + stickerOnly -> 2.dp + mediaOnlyBubble -> 0.dp + else -> BUBBLE_CONTENT_VPAD + }, + ), verticalArrangement = Arrangement.spacedBy(if (showSender) 1.dp else 4.dp), ) { if (showSender && senderTitle != null) { @@ -423,30 +403,28 @@ fun MessageBubble( color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .weight(1f, fill = false) - .then( - if (onOpenSender != null) { - Modifier.clickable(onClick = onOpenSender) - } else { - Modifier - }, - ), + modifier = Modifier + .weight(1f, fill = false) + .then( + if (onOpenSender != null) { + Modifier.clickable(onClick = onOpenSender) + } else { + Modifier + }, + ), ) sender?.emojiStatusDocumentId?.let { documentId -> CustomEmojiGlyph( documentId = documentId, size = 16.dp, - onClick = - onOpenStickerPack?.let { open -> - { - open( - documentId, - true, - ) - } - }, + onClick = onOpenStickerPack?.let { open -> + { + open( + documentId, + true, + ) + } + }, ) } senderTagLabel(senderTag)?.let { tag -> @@ -456,36 +434,32 @@ fun MessageBubble( color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .clip(MaterialTheme.shapes.extraSmall) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)) - .padding(horizontal = 6.dp, vertical = 1.dp), + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)) + .padding(horizontal = 6.dp, vertical = 1.dp), ) } } } if (!message.fwdFrom.isNullOrBlank() || message.fwdFromId != null || message.fwdDate != null) { Column( - modifier = - Modifier - .then( - if (onOpenForwardSource != null) { - Modifier - .heightIn(min = 48.dp) - .clickable(onClick = onOpenForwardSource) - } else { - Modifier - }, - ) - .padding(vertical = 3.dp, horizontal = edgeContentPad), + modifier = Modifier + .then( + if (onOpenForwardSource != null) { + Modifier.heightIn(min = 48.dp) + .clickable(onClick = onOpenForwardSource) + } else { + Modifier + }, + ) + .padding(vertical = 3.dp, horizontal = edgeContentPad), verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - text = - message.fwdFrom?.takeIf { it.isNotBlank() }?.let { - stringResource(R.string.dialog_forwarded_from, it) - } ?: stringResource(R.string.dialog_forwarded_message), + text = message.fwdFrom?.takeIf { it.isNotBlank() }?.let { + stringResource(R.string.dialog_forwarded_from, it) + } ?: stringResource(R.string.dialog_forwarded_message), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, maxLines = 2, @@ -540,24 +514,22 @@ fun MessageBubble( messages = album, mediaRepository = mediaRepository, shape = shape, - onLongPress = - onLongPress ?: onOpenMenu?.let { open -> - { - open( - bubblePosition, - ) - } - }, - modifier = - if (mediaOnlyBubble) { - Modifier - } else { - Modifier.bleedBubbleMedia( - horizontal = 0.dp, - bleedTop = mediaBleedTop, - bleedBottom = mediaBleedBottom, + onLongPress = onLongPress ?: onOpenMenu?.let { open -> + { + open( + bubblePosition, ) - }, + } + }, + modifier = if (mediaOnlyBubble) { + Modifier + } else { + Modifier.bleedBubbleMedia( + horizontal = 0.dp, + bleedTop = mediaBleedTop, + bleedBottom = mediaBleedBottom, + ) + }, ) } else if (isNonVisual) { Column( @@ -569,23 +541,21 @@ fun MessageBubble( message = docMessage, mediaRepository = mediaRepository, edgeToEdge = false, - onStickerClick = - onOpenStickerPack?.let { open -> - { - stickerDocumentId(docMessage.mediaCacheKey)?.let { id -> - open(id, false) - } + onStickerClick = onOpenStickerPack?.let { open -> + { + stickerDocumentId(docMessage.mediaCacheKey)?.let { id -> + open(id, false) } - }, + } + }, onInstantView = onInstantView, - onLongPress = - onLongPress ?: onOpenMenu?.let { open -> - { - open( - bubblePosition, - ) - } - }, + onLongPress = onLongPress ?: onOpenMenu?.let { open -> + { + open( + bubblePosition, + ) + } + }, modifier = Modifier.fillMaxWidth(), ) } @@ -596,48 +566,44 @@ fun MessageBubble( mediaRepository = mediaRepository, onClosePoll = onClosePoll?.let { close -> { close(message.id.id) } }, pollVoting = pollVoting, - onPollVote = - onPollVote?.let { vote -> - { options -> - vote( - message.id.id, - options, - ) - } - }, + onPollVote = onPollVote?.let { vote -> + { options -> + vote( + message.id.id, + options, + ) + } + }, onShowPollVoters = onShowPollVoters, ) MessageMedia( message = message, mediaRepository = mediaRepository, edgeToEdge = singleVisualMedia, - onStickerClick = - onOpenStickerPack?.let { open -> - { - stickerDocumentId(message.mediaCacheKey)?.let { id -> - open(id, false) - } + onStickerClick = onOpenStickerPack?.let { open -> + { + stickerDocumentId(message.mediaCacheKey)?.let { id -> + open(id, false) } - }, + } + }, onInstantView = onInstantView, - onLongPress = - onLongPress ?: onOpenMenu?.let { open -> - { - open( - bubblePosition, - ) - } - }, - modifier = - if (singleVisualMedia) { - Modifier.bleedBubbleMedia( - horizontal = 0.dp, - bleedTop = mediaBleedTop, - bleedBottom = mediaBleedBottom, + onLongPress = onLongPress ?: onOpenMenu?.let { open -> + { + open( + bubblePosition, ) - } else { - Modifier - }, + } + }, + modifier = if (singleVisualMedia) { + Modifier.bleedBubbleMedia( + horizontal = 0.dp, + bleedTop = mediaBleedTop, + bleedBottom = mediaBleedBottom, + ) + } else { + Modifier + }, ) } if (mediaOverlayMeta) { @@ -646,23 +612,21 @@ fun MessageBubble( time = messageTime(message.date), color = Color.White, showReadStatus = showReadStatus, - modifier = - Modifier - .align(Alignment.BottomEnd) - .padding(8.dp) - .clip(RoundedCornerShape(6.dp)) - .background(Color.Black.copy(alpha = 0.5f)) - .padding(horizontal = 6.dp, vertical = 2.dp), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color.Black.copy(alpha = 0.5f)) + .padding(horizontal = 6.dp, vertical = 2.dp), ) } } message.checklist?.let { list -> Column( - modifier = - Modifier - .fillMaxWidthInBubble() - .padding(horizontal = 12.dp, vertical = 8.dp) - .animateContentSize(), + modifier = Modifier + .fillMaxWidthInBubble() + .padding(horizontal = 12.dp, vertical = 8.dp) + .animateContentSize(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { if (list.title.isNotBlank()) { @@ -694,20 +658,16 @@ fun MessageBubble( ) androidx.compose.material3.LinearProgressIndicator( progress = { completion }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), drawStopIndicator = {}, ) if (checklistBusy) { - org.monogram.core.ui.loading - .MonogramLoading() + 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 } + 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( @@ -715,19 +675,17 @@ fun MessageBubble( label = "taskColor", ) val enabled = - onToggleChecklist != null && !checklistBusy && !message.pending && message.id.id > 0 && - (message.outgoing || list.othersCanComplete) + onToggleChecklist != null && !checklistBusy && !message.pending && message.id.id > 0 && (message.outgoing || list.othersCanComplete) Row( - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .toggleable( - value = item.done, - enabled = enabled, - role = androidx.compose.ui.semantics.Role.Checkbox, - onValueChange = { onToggleChecklist?.invoke(item.id) }, - ), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .toggleable( + value = item.done, + enabled = enabled, + role = androidx.compose.ui.semantics.Role.Checkbox, + onValueChange = { onToggleChecklist?.invoke(item.id) }, + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -739,10 +697,9 @@ fun MessageBubble( modifier = Modifier.weight(1f), text = item.text, entities = item.entities, - textStyle = - MaterialTheme.typography.bodyMedium.copy( - textDecoration = if (item.done) androidx.compose.ui.text.style.TextDecoration.LineThrough else null, - ), + textStyle = MaterialTheme.typography.bodyMedium.copy( + textDecoration = if (item.done) androidx.compose.ui.text.style.TextDecoration.LineThrough else null, + ), contentColor = itemColor, linkColor = MaterialTheme.colorScheme.primary, revealSpoilers = spoilersVisible, @@ -768,10 +725,9 @@ fun MessageBubble( ) { Icon( Icons.Outlined.MoreVert, - contentDescription = - stringResource( - org.monogram.core.ui.R.string.media_more_actions, - ), + contentDescription = stringResource( + org.monogram.core.ui.R.string.media_more_actions, + ), ) } AppMenuPopup( @@ -784,15 +740,13 @@ fun MessageBubble( Column { if (canEdit) { AppMenuItem( - text = - stringResource( - R.string.dialog_checklist_edit_item, - item.text, - ), + text = stringResource( + R.string.dialog_checklist_edit_item, + item.text, + ), icon = Icons.Outlined.Edit, onClick = { - menuOpen = - false + menuOpen = false onEditChecklistItem?.invoke( item.id, ) @@ -801,16 +755,14 @@ fun MessageBubble( } if (canRemove) { AppMenuItem( - text = - stringResource( - R.string.dialog_checklist_remove_item, - item.text, - ), + text = stringResource( + R.string.dialog_checklist_remove_item, + item.text, + ), icon = Icons.Outlined.Delete, onClick = { menuOpen = false - confirmRemoval = - true + confirmRemoval = true }, ) } @@ -834,8 +786,7 @@ fun MessageBubble( androidx.compose.material3.TextButton( enabled = !checklistBusy, onClick = { - confirmRemoval = - false + confirmRemoval = false onRemoveChecklistItem?.invoke(item.id) }, ) { Text(stringResource(R.string.dialog_delete)) } @@ -854,29 +805,27 @@ fun MessageBubble( } if (addEnabled) { Row( - modifier = - Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.large) - .background(MaterialTheme.colorScheme.secondaryContainer) - .heightIn(min = 48.dp) - .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(), - ) - } + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.secondaryContainer) + .heightIn(min = 48.dp) + .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(), + ) } - .padding(horizontal = 12.dp), + } + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = - Arrangement.spacedBy( - 8.dp, - Alignment.CenterHorizontally, - ), + horizontalArrangement = Arrangement.spacedBy( + 8.dp, + Alignment.CenterHorizontally, + ), ) { Icon( imageVector = Icons.Filled.Add, @@ -907,15 +856,12 @@ fun MessageBubble( renderedBlocks, ) { mutableStateOf(null) } val hasWideChrome = - message.replyToMsgId != null || !quote.isNullOrBlank() || message.mediaKind != null || showSender || - !message.fwdFrom.isNullOrBlank() || - !message.viaBot.isNullOrBlank() + message.replyToMsgId != null || !quote.isNullOrBlank() || message.mediaKind != null || showSender || !message.fwdFrom.isNullOrBlank() || !message.viaBot.isNullOrBlank() TextWithTimestampLayout( textLayoutInfo = textLayoutInfo, - modifier = - Modifier - .padding(horizontal = edgeContentPad) - .then(if (hasWideChrome) Modifier.fillMaxWidthInBubble() else Modifier), + modifier = Modifier + .padding(horizontal = edgeContentPad) + .then(if (hasWideChrome) Modifier.fillMaxWidthInBubble() else Modifier), horizontalPadding = 8, stackedTopPadding = 2, textContent = { @@ -956,18 +902,14 @@ fun MessageBubble( } AnimatedVisibility( visible = onRetrySend != null, - enter = - fadeIn(tween(160)) + - expandVertically( - tween(220), - expandFrom = Alignment.Top, - ), - exit = - fadeOut(tween(100)) + - shrinkVertically( - tween(180), - shrinkTowards = Alignment.Top, - ), + enter = fadeIn(tween(160)) + expandVertically( + tween(220), + expandFrom = Alignment.Top, + ), + exit = fadeOut(tween(100)) + shrinkVertically( + tween(180), + shrinkTowards = Alignment.Top, + ), ) { OutgoingBubbleFailure(sendUncertain, sendError) { onRetrySend?.invoke() } } @@ -976,17 +918,15 @@ fun MessageBubble( repliesCount = message.repliesCount, onClick = onComments, contentColor = onContainer, - modifier = - Modifier - .fillMaxWidthInBubble() - .bleedCommentFooter(edgeVisualMedia), + modifier = Modifier + .fillMaxWidthInBubble() + .bleedCommentFooter(edgeVisualMedia), ) } } - val inlineMarkup = - message.replyMarkup?.takeIf { - it.kind == ReplyMarkupKind.Inline && it.rows.isNotEmpty() - } + val inlineMarkup = message.replyMarkup?.takeIf { + it.kind == ReplyMarkupKind.Inline && it.rows.isNotEmpty() + } if (inlineMarkup != null && onMarkupButton != null) { BotKeyboardGrid( markup = inlineMarkup, @@ -1001,18 +941,16 @@ fun MessageBubble( onReact = onReact, onAddReaction = onAddReaction, onShowUsers = onShowReactionUsers, - modifier = - Modifier - .fillMaxWidthInBubble() - .then( - if (hasComments) { - Modifier.padding(start = 2.dp, end = 2.dp, top = 4.dp) - } else { - Modifier - .offset(y = (-4).dp) - .padding(start = 2.dp, end = 2.dp, top = 6.dp) - }, - ), + modifier = Modifier + .fillMaxWidthInBubble() + .then( + if (hasComments) { + Modifier.padding(start = 2.dp, end = 2.dp, top = 4.dp) + } else { + Modifier.offset(y = (-4).dp) + .padding(start = 2.dp, end = 2.dp, top = 6.dp) + }, + ), ) } } @@ -1040,41 +978,38 @@ internal fun Modifier.bleedBubbleMedia( vertical: Dp = BUBBLE_CONTENT_VPAD, bleedTop: Boolean = false, bleedBottom: Boolean = false, -): Modifier = - layout { measurable, constraints -> - val hInset = horizontal.roundToPx() - val vInset = vertical.roundToPx() - if (!constraints.hasBoundedWidth) { - val placeable = measurable.measure(constraints) - return@layout layout(placeable.width, placeable.height) { placeable.place(0, 0) } - } - val slot = constraints.maxWidth + hInset * 2 - val placeable = - measurable.measure( - constraints.copy(minWidth = 0, maxWidth = slot, minHeight = 0), - ) - val x = -hInset + (slot - placeable.width) / 2 - val y = if (bleedTop) -vInset else 0 - val height = - (placeable.height - (if (bleedTop) vInset else 0) - (if (bleedBottom) vInset else 0)).coerceAtLeast( - 0, - ) - layout(constraints.maxWidth, height) { - placeable.place(x, y) - } +): Modifier = layout { measurable, constraints -> + val hInset = horizontal.roundToPx() + val vInset = vertical.roundToPx() + if (!constraints.hasBoundedWidth) { + val placeable = measurable.measure(constraints) + return@layout layout(placeable.width, placeable.height) { placeable.place(0, 0) } } + val slot = constraints.maxWidth + hInset * 2 + val placeable = measurable.measure( + constraints.copy(minWidth = 0, maxWidth = slot, minHeight = 0), + ) + val x = -hInset + (slot - placeable.width) / 2 + val y = if (bleedTop) -vInset else 0 + val height = + (placeable.height - (if (bleedTop) vInset else 0) - (if (bleedBottom) vInset else 0)).coerceAtLeast( + 0, + ) + layout(constraints.maxWidth, height) { + placeable.place(x, y) + } +} private object FillMaxWidthInBubble : LayoutModifier { override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, ): MeasureResult { - val childConstraints = - if (constraints.hasBoundedWidth) { - constraints.copy(minWidth = constraints.maxWidth, maxWidth = constraints.maxWidth) - } else { - constraints.copy(minWidth = 0) - } + val childConstraints = if (constraints.hasBoundedWidth) { + constraints.copy(minWidth = constraints.maxWidth, maxWidth = constraints.maxWidth) + } else { + constraints.copy(minWidth = 0) + } val placeable = measurable.measure(childConstraints) return layout(placeable.width, placeable.height) { placeable.place(0, 0) @@ -1110,26 +1045,24 @@ private suspend fun PointerInputScope.menuOrHold( val down = awaitFirstDown(requireUnconsumed = false) val slop = viewConfiguration.touchSlop var claimed = false - val released = - withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis + 48) { - var current = down - while (current.pressed) { - val event = awaitPointerEvent() - val change = - event.changes.firstOrNull { it.id == down.id } - ?: return@withTimeoutOrNull false - if (change.isConsumed) { - claimed = true - return@withTimeoutOrNull false - } - val delta = change.position - down.position - if (delta.x * delta.x + delta.y * delta.y > slop * slop) { - return@withTimeoutOrNull false - } - current = change + val released = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis + 48) { + var current = down + while (current.pressed) { + val event = awaitPointerEvent() + val change = + event.changes.firstOrNull { it.id == down.id } ?: return@withTimeoutOrNull false + if (change.isConsumed) { + claimed = true + return@withTimeoutOrNull false + } + val delta = change.position - down.position + if (delta.x * delta.x + delta.y * delta.y > slop * slop) { + return@withTimeoutOrNull false } - true + current = change } + true + } when { released == true -> { down.consume() 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/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/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/ui/SettingsContent.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt index 681fd0921..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 @@ -132,170 +132,163 @@ fun SettingsContent( val layoutDirection = LocalLayoutDirection.current val direction = if (layoutDirection == LayoutDirection.Ltr) 1f else -1f val motion = mediaViewerMotionEnabled() - val pageBackHandler = - remember(component, gestureDispatcher) { - MultiBackHandler(listOfNotNull(component.backHandler, gestureDispatcher)) - } + val pageBackHandler = remember(component, gestureDispatcher) { + MultiBackHandler(listOfNotNull(component.backHandler, gestureDispatcher)) + } SideEffect { component.setPageBackHandler(folders?.onBack) } DisposableEffect(Unit) { onDispose { component.setPageBackHandler(null) } } - val pageAnimation = - remember(pageBackHandler, direction, motion) { - val spec = - tween(durationMillis = if (motion) 280 else 0, easing = FastOutSlowInEasing) - predictiveBackAnimation( - backHandler = pageBackHandler, - fallbackAnimation = stackAnimation(fade(animationSpec = spec) + slide(animationSpec = spec)), - selector = { event, _, _ -> - predictiveBackAnimatable( - initialBackEvent = event, - exitModifier = { progress, _ -> - Modifier.graphicsLayer { - translationX = size.width * progress * direction - } - }, - enterModifier = { progress, _ -> - Modifier.graphicsLayer { - translationX = -size.width * 0.25f * (1f - progress) * direction - } - }, - ) - }, - onBack = component::popPage, - ) - } - val pageStates = rememberSaveableStateHolder() - val profile = state.profile - 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 pageAnimation = remember(pageBackHandler, direction, motion) { + val spec = + tween(durationMillis = if (motion) 280 else 0, easing = FastOutSlowInEasing) + predictiveBackAnimation( + backHandler = pageBackHandler, + fallbackAnimation = stackAnimation(fade(animationSpec = spec) + slide(animationSpec = spec)), + selector = { event, _, _ -> + predictiveBackAnimatable( + initialBackEvent = event, + exitModifier = { progress, _ -> + Modifier.graphicsLayer { + translationX = size.width * progress * direction + } + }, + enterModifier = { progress, _ -> + Modifier.graphicsLayer { + translationX = -size.width * 0.25f * (1f - progress) * direction + } + }, + ) }, + onBack = component::popPage, ) - val selfAvatarPreview = - rememberStrippedPreview( - Triple( - component.mediaRepository, - profile?.id, - profile?.avatarCacheKey - ) - ) { - profile?.id?.let { - component.mediaRepository?.peerInlineThumbJpeg( - it, - profile?.avatarCacheKey + } + val pageStates = rememberSaveableStateHolder() + val profile = state.profile + 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) } - }, - ensure = { - val repo = component.mediaRepository ?: return@rememberEnsuredFile null - val peer = profile?.id ?: return@rememberEnsuredFile null - val key = peerAvatarCacheKey(peer, profile.avatarCacheKey) - when (val result = repo.ensureLocalAvatar(peer, key)) { - is Outcome.Ok -> result.value - is Outcome.Err -> null - } - }, - ) + } + val avatarFile = rememberEnsuredFile( + generation = cacheGeneration, + identity = profile?.id?.value to profile?.avatarCacheKey, + resolve = { + profile?.avatarCacheKey?.let { component.mediaRepository?.cachedFile(it) } + }, + ensure = { + val repo = component.mediaRepository ?: return@rememberEnsuredFile null + val peer = profile?.id ?: return@rememberEnsuredFile null + val key = peerAvatarCacheKey(peer, profile.avatarCacheKey) + when (val result = repo.ensureLocalAvatar(peer, key)) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + }, + ) val accountTitle = profile?.title ?: stringResource(R.string.settings_account) - val accountSubtitle = - profileSubtitle( - username = profile?.username, - phone = profile?.phone, - loaded = profile != null, - ) - val title = - when (page) { - SettingsPage.Home -> { - stringResource(R.string.settings_title) - } + val accountSubtitle = profileSubtitle( + username = profile?.username, + phone = profile?.phone, + loaded = profile != null, + ) + val title = when (page) { + SettingsPage.Home -> { + stringResource(R.string.settings_title) + } - SettingsPage.Passkeys -> { - stringResource(R.string.settings_passkeys) - } + SettingsPage.Passkeys -> { + stringResource(R.string.settings_passkeys) + } - SettingsPage.Devices -> { - stringResource(R.string.settings_devices) - } + SettingsPage.Devices -> { + stringResource(R.string.settings_devices) + } - SettingsPage.DeviceScanner -> { - stringResource(R.string.settings_devices_link) - } + SettingsPage.DeviceScanner -> { + stringResource(R.string.settings_devices_link) + } - SettingsPage.Proxy -> { - stringResource(R.string.settings_proxy) - } + SettingsPage.Proxy -> { + stringResource(R.string.settings_proxy) + } - SettingsPage.Data -> { - stringResource(R.string.settings_data) - } + SettingsPage.Data -> { + stringResource(R.string.settings_data) + } - SettingsPage.DebugStats -> { - stringResource(R.string.settings_debug_stats) - } + SettingsPage.DebugStats -> { + stringResource(R.string.settings_debug_stats) + } - SettingsPage.Logs -> { - stringResource(R.string.settings_logs) - } + 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 - }, - ) - } + 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.Appearance -> { + stringResource(R.string.settings_chat) + } - SettingsPage.Notifications -> { - stringResource(R.string.settings_notifications) - } + 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 - }, - ) - } + 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.NotificationExceptions -> { + stringResource(R.string.settings_notifications_exceptions) + } - SettingsPage.NotificationDebug -> { - stringResource(R.string.settings_notifications_debug) - } + SettingsPage.NotificationDebug -> { + stringResource(R.string.settings_notifications_debug) + } - SettingsPage.Wallpaper -> { - stringResource(R.string.settings_wallpaper) - } + SettingsPage.Wallpaper -> { + stringResource(R.string.settings_wallpaper) + } - SettingsPage.Folders -> { - folders?.title ?: stringResource(R.string.settings_folders) - } + SettingsPage.Folders -> { + folders?.title ?: stringResource(R.string.settings_folders) } + } Scaffold( modifier = modifier.fillMaxSize(), @@ -316,12 +309,11 @@ fun SettingsContent( if (home) { AppBarSyncTitle( idleTitle = barTitle, - sync = - when { - state.loading && profile == null -> AppSyncStatus.Connecting - state.loading -> AppSyncStatus.Syncing - else -> AppSyncStatus.Hidden - }, + sync = when { + state.loading && profile == null -> AppSyncStatus.Connecting + state.loading -> AppSyncStatus.Syncing + else -> AppSyncStatus.Hidden + }, ) } else { Text( @@ -365,11 +357,10 @@ fun SettingsContent( LogsTopActions( onShare = { val kind = debugLogKind(logKindName) - logShareFailed = - !shareDebugLog( - logContext, - DebugLog.exportText(kind, logQuery), - ) + logShareFailed = !shareDebugLog( + logContext, + DebugLog.exportText(kind, logQuery), + ) }, onClear = { confirmClearLogs = true }, ) @@ -408,10 +399,9 @@ fun SettingsContent( } } }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), ) } }, @@ -479,10 +469,8 @@ fun SettingsContent( selfPeerId = state.profile?.id?.value, updateState = updateState, updatesEnabled = component.updatesEnabled, - devicesSubtitle = - activeDeviceCount.sessions - .count { !it.current && !it.passwordPending } - .takeIf { it > 0 }, + devicesSubtitle = activeDeviceCount.sessions.count { !it.current && !it.passwordPending } + .takeIf { it > 0 }, apiLayer = state.apiLayer, accountUserIds = component.accountUserIds, accountDetails = accountDetails, @@ -522,18 +510,16 @@ fun SettingsContent( 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 - }, + onOpenDebugStats = if (DebugStats.enabled) { + { component.openPage(SettingsPage.DebugStats) } + } else { + null + }, + onOpenLogs = if (DebugLog.enabled) { + { component.openPage(SettingsPage.Logs) } + } else { + null + }, mediaRepository = component.mediaRepository, ) } @@ -564,12 +550,11 @@ fun SettingsContent( exportMessage = state.debugExportMessage, expanded = expanded, onToggleSection = { key -> - debugStatsExpanded = - if (key in expanded) { - expanded.minus(key).joinToString(",") - } else { - expanded.plus(key).joinToString(",") - } + debugStatsExpanded = if (key in expanded) { + expanded.minus(key).joinToString(",") + } else { + expanded.plus(key).joinToString(",") + } }, onExport = component::onExportDebugStats, onClear = component::onClearDebugStats, @@ -673,10 +658,9 @@ fun SettingsContent( confirmRemoveAccount?.let(component::removeAccount) confirmRemoveAccount = null }, - colors = - ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), ) { Text(stringResource(R.string.settings_remove_account)) } }, dismissButton = { @@ -708,10 +692,9 @@ fun SettingsContent( TextButton( onClick = { component.onLogout() }, enabled = !state.loggingOut, - colors = - ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), ) { Text( stringResource( @@ -750,10 +733,9 @@ fun SettingsContent( byKind = state.cacheByKind, selected = selected, onToggle = { kind -> - clearSelection = - selected.let { current -> - if (kind in current) current - kind else current + kind - } + clearSelection = selected.let { current -> + if (kind in current) current - kind else current + kind + } }, onDismiss = { clearSelection = null }, onConfirm = { @@ -787,10 +769,9 @@ fun SettingsContent( confirmClearLogs = false DebugLog.clear() }, - colors = - ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), ) { Text(stringResource(R.string.settings_logs_clear_action)) } @@ -810,45 +791,39 @@ private fun SettingsPageList( content: LazyListScope.() -> Unit, ) { LazyColumn( - modifier = - Modifier - .fillMaxSize() - .wrapContentWidth(Alignment.CenterHorizontally) - .widthIn(max = 720.dp), - contentPadding = - PaddingValues( - start = 16.dp, - top = innerPadding.calculateTopPadding() + 8.dp, - end = 16.dp, - bottom = - WindowInsets.navigationBars - .asPaddingValues() - .calculateBottomPadding() + 16.dp, - ), + modifier = Modifier + .fillMaxSize() + .wrapContentWidth(Alignment.CenterHorizontally) + .widthIn(max = 720.dp), + contentPadding = PaddingValues( + start = 16.dp, + top = innerPadding.calculateTopPadding() + 8.dp, + end = 16.dp, + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp, + ), verticalArrangement = Arrangement.spacedBy(0.dp), content = content, ) } -private fun settingsPageKey(page: SettingsPage): String = - when (page) { - SettingsPage.Home -> "home" - SettingsPage.Passkeys -> "passkeys" - SettingsPage.Devices -> "devices" - SettingsPage.DeviceScanner -> "device-scanner" - SettingsPage.Folders -> "folders" - SettingsPage.Data -> "data" - SettingsPage.Proxy -> "proxy" - SettingsPage.DebugStats -> "debug-stats" - SettingsPage.Logs -> "logs" - is SettingsPage.AutoDownload -> "autodownload:${page.network}" - SettingsPage.Appearance -> "appearance" - SettingsPage.Wallpaper -> "wallpaper" - SettingsPage.Notifications -> "notifications" - is SettingsPage.NotificationCategory -> "notifications:${page.kind}" - SettingsPage.NotificationExceptions -> "notifications:exceptions" - SettingsPage.NotificationDebug -> "notifications:debug" - } +private fun settingsPageKey(page: SettingsPage): String = when (page) { + SettingsPage.Home -> "home" + SettingsPage.Passkeys -> "passkeys" + SettingsPage.Devices -> "devices" + SettingsPage.DeviceScanner -> "device-scanner" + SettingsPage.Folders -> "folders" + SettingsPage.Data -> "data" + SettingsPage.Proxy -> "proxy" + SettingsPage.DebugStats -> "debug-stats" + SettingsPage.Logs -> "logs" + is SettingsPage.AutoDownload -> "autodownload:${page.network}" + SettingsPage.Appearance -> "appearance" + SettingsPage.Wallpaper -> "wallpaper" + SettingsPage.Notifications -> "notifications" + is SettingsPage.NotificationCategory -> "notifications:${page.kind}" + SettingsPage.NotificationExceptions -> "notifications:exceptions" + SettingsPage.NotificationDebug -> "notifications:debug" +} private class MultiBackHandler( private val handlers: List, diff --git a/native/Cargo.lock b/native/Cargo.lock index ede9f467f..71397fe01 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -873,7 +873,7 @@ dependencies = [ [[package]] name = "mono-proto" -version = "0.2.0+layer.232" +version = "0.2.0+layer.233" dependencies = [ "bnum", "mono-proto-codec", 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 a3788beb0..a28f250b2 100644 --- a/native/mtproto-rs/src/ffi.rs +++ b/native/mtproto-rs/src/ffi.rs @@ -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/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/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/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/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/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/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/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 ccf3bdeba..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 @@ -105,18 +105,11 @@ internal class UpdatesPump( } while (true) { val activeHandle = core.activeHandleOrZero() - if (activeHandle == 0L || core.sessionDead || core.connectedHandle != activeHandle || - core.updatesStartedHandle != activeHandle - ) { - if (activeHandle != 0L && !core.sessionDead && - core.connectedHandle == activeHandle && - core.updatesStartRetryHandle == 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 - ) { + while (core.isCurrentHandle(activeHandle) && !core.sessionDead && core.updatesStartedHandle != activeHandle) { if (core.updatesWake.receiveCatching().isClosed) return@withTimeoutOrNull } } @@ -158,70 +151,66 @@ internal class UpdatesPump( lastChatsRefreshMs.set(-1L) lastPremiumRefreshMs.set(-1L) } - val drain = - try { - if (!replayPendingBatches(activeHandle)) { - delay(drainDelayMs) - continue - } - publishCompletionIfReady(activeHandle) - val drainAt = if (PerfLog.isEnabled()) System.nanoTime() else 0L - val drained = - nativeRequest( - core.native, - core.nativeDispatcher, - DispatchClass.BACKGROUND_READ, - ) { - core.native.drainUpdateBatches(activeHandle) - } - val count = drained.batches.sumOf { it.events.size } + drained.liveEvents.size - if (count > 0) { - PerfLog.noteUpdateDrain(count) - } - if (drainAt != 0L && count > 0) { - PerfLog.trace( - op = "updates", - phase = "drain", - elapsedMs = (System.nanoTime() - drainAt) / 1_000_000, - handle = activeHandle, - dispatchClass = DispatchClass.BACKGROUND_READ, - result = "ok", - detail = "count=$count batches=${drained.batches.size} lane=${ - dispatchClassName( - DispatchClass.BACKGROUND_READ, - ) - }", - ) - } - drainDelayMs = 25L - reconnectHandle = 0L - if (drained.replayRequired) { - core.replayPending.set(true) - if (core.durableSink == null) drainDelayMs = REPLAY_WAIT_MS - } - 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 - ) { - reconnectHandle = activeHandle - } - drainDelayMs = (drainDelayMs * 2).coerceIn(4_000L, 30_000L) + val drain = try { + if (!replayPendingBatches(activeHandle)) { + delay(drainDelayMs) + continue + } + publishCompletionIfReady(activeHandle) + val drainAt = if (PerfLog.isEnabled()) System.nanoTime() else 0L + val drained = nativeRequest( + core.native, + core.nativeDispatcher, + DispatchClass.BACKGROUND_READ, + ) { + core.native.drainUpdateBatches(activeHandle) + } + val count = drained.batches.sumOf { it.events.size } + drained.liveEvents.size + if (count > 0) { + PerfLog.noteUpdateDrain(count) + } + if (drainAt != 0L && count > 0) { + PerfLog.trace( + op = "updates", + phase = "drain", + elapsedMs = (System.nanoTime() - drainAt) / 1_000_000, + handle = activeHandle, + dispatchClass = DispatchClass.BACKGROUND_READ, + result = "ok", + detail = "count=$count batches=${drained.batches.size} lane=${ + dispatchClassName( + DispatchClass.BACKGROUND_READ, + ) + }", + ) + } + drainDelayMs = 25L + reconnectHandle = 0L + if (drained.replayRequired) { + core.replayPending.set(true) + if (core.durableSink == null) drainDelayMs = REPLAY_WAIT_MS + } + 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) { + reconnectHandle = activeHandle } - UpdateDrainDto(emptyList(), emptyList(), false) + drainDelayMs = (drainDelayMs * 2).coerceIn(4_000L, 30_000L) } + UpdateDrainDto(emptyList(), emptyList(), false) + } if (drain.batches.isNotEmpty() || drain.replayRequired) { durablePendingHandle = activeHandle } @@ -236,8 +225,7 @@ internal class UpdatesPump( // 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() - if (foldersRefreshPending && - cooldownDue( + if (foldersRefreshPending && cooldownDue( lastMetadataRefreshMs, 2_000L, now, @@ -245,10 +233,9 @@ internal class UpdatesPump( ) { core.scope.launch { try { - val folders = - core.onNativeIfFree { - core.native.getFolders(it).map { dto -> dto.toFolderModel() } - } + val folders = core.onNativeIfFree { + core.native.getFolders(it).map { dto -> dto.toFolderModel() } + } if (folders != null && core.isCurrentHandle(activeHandle)) { lastMetadataRefreshMs.set(core.clock.elapsedMs()) foldersRefreshPending = false @@ -257,26 +244,23 @@ internal class UpdatesPump( } catch (e: CancellationException) { throw e } catch (e: Exception) { - val raw = - when (e) { - is uniffi.monogram_mtproto.MtprotoException.Message -> e.v1 - else -> e.message - }?.removePrefix("v1=")?.trim().orEmpty() - val telegram = - TelegramError.parse( - raw.ifBlank { "refresh folders failed" }, - "refresh folders failed", + val raw = when (e) { + is uniffi.monogram_mtproto.MtprotoException.Message -> e.v1 + else -> e.message + }?.removePrefix("v1=")?.trim().orEmpty() + val telegram = TelegramError.parse( + raw.ifBlank { "refresh folders failed" }, + "refresh folders failed", + ) + val waitSec = if (telegram.kind == TelegramError.Kind.Flood) { + (telegram.retryAfterSeconds ?: telegram.argument ?: 3).coerceIn( + 3, + 60, ) - val waitSec = - if (telegram.kind == TelegramError.Kind.Flood) { - (telegram.retryAfterSeconds ?: telegram.argument ?: 3).coerceIn( - 3, - 60 - ) - } else { - core.fail(e, "refresh folders failed", degradeHome = false) - 15 - } + } else { + core.fail(e, "refresh folders failed", degradeHome = false) + 15 + } lastMetadataRefreshMs.set( core.clock.elapsedMs() + (waitSec - 2).toLong() * 1_000L, ) @@ -285,8 +269,7 @@ internal class UpdatesPump( } } } - if (chatsRefreshPending && core.isCurrentHandle(activeHandle) && - cooldownDue( + if (chatsRefreshPending && core.isCurrentHandle(activeHandle) && cooldownDue( lastChatsRefreshMs, 2_000L, now, @@ -312,28 +295,26 @@ internal class UpdatesPump( } } } - if (premiumRefreshPending && core.isCurrentHandle(activeHandle) && - cooldownDue( + if (premiumRefreshPending && core.isCurrentHandle(activeHandle) && cooldownDue( lastPremiumRefreshMs, 60_000L, now, ) && premiumInFlight.compareAndSet(false, true) ) { core.scope.launch { - val profile = - try { - core.onNativeIfFree { handle -> - core.native.getProfile(handle, 0L).toProfileModel() - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - lastPremiumRefreshMs.set(core.clock.elapsedMs()) - core.fail(e, "refresh account failed", degradeHome = false) - null - } finally { - premiumInFlight.set(false) + val profile = try { + core.onNativeIfFree { handle -> + core.native.getProfile(handle, 0L).toProfileModel() } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + lastPremiumRefreshMs.set(core.clock.elapsedMs()) + core.fail(e, "refresh account failed", degradeHome = false) + null + } finally { + premiumInFlight.set(false) + } if (profile != null && core.isCurrentHandle(activeHandle)) { lastPremiumRefreshMs.set(core.clock.elapsedMs()) premiumRefreshPending = false @@ -351,6 +332,23 @@ internal class UpdatesPump( } } + 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) { @@ -367,40 +365,37 @@ internal class UpdatesPump( batch: UpdateBatchDto, ): Boolean { val sink = core.durableSink ?: return false - val mapped = - try { - batch.events.map { it.toMtprotoUpdates() } - } 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 = mapped.flatten(), - cursor = - batch.cursorAfter?.let { - UpdatesCursor( - it.pts, - it.qts, - it.date, - it.seq, - ) - }, - channelCursors = batch.channelPtsAfter.associate { it.chatId to it.pts }, - ), - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - AppLog.warn("updates", "durable commit failed") - 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 = mapped.flatten(), + cursor = batch.cursorAfter?.let { + UpdatesCursor( + it.pts, + it.qts, + it.date, + it.seq, + ) + }, + channelCursors = batch.channelPtsAfter.associate { it.chatId to it.pts }, + ), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.warn("updates", "durable commit failed") + false + } if (!committed || !core.isCurrentHandle(handle)) return false if ((batch.accountGeneration to batch.batchId) !in emitted) { deliverEvents(batch.events, batch.accountGeneration, batch.batchId, handle, mapped) @@ -472,24 +467,23 @@ internal class UpdatesPump( } else -> { - (mapped?.get(index) ?: event.toMtprotoUpdates()).forEach { update -> - val ordered = - when (update) { - is MtprotoUpdate.NewMessage -> { - update.copy( - accountGeneration = accountGeneration, - acceptedOrder = acceptedOrder, - ) - } + (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) - } + is MtprotoUpdate.MessagesDeleted -> { + update.copy(persisted = mapped != null) + } - else -> { - update - } + else -> { + update } + } core.updatesEvents.emit(ordered) } } 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 5fa7fceec..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 @@ -41,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 @@ -65,10 +67,9 @@ internal fun streamPartKey( 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}" - } +internal fun streamMediaKey(message: Message): String? = message.mediaCacheKey?.let { + "$it:chat:${message.id.chatId.value}:message:${message.id.id}:size:${message.fileSize ?: 0}" +} /** A prefix part the player can read without fetching again. */ internal fun reusableStreamPrefix( @@ -76,7 +77,7 @@ internal fun reusableStreamPrefix( offset: Long = 0, 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 } } @@ -108,6 +109,15 @@ 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, @@ -150,13 +160,12 @@ class MediaRepository( fun createCachedDataSourceFactory(key: String): DataSource.Factory = DataSource.Factory { LeasedFileDataSource(cache, key) } - private val cache = - FileCache( - cacheRoot, - maxBytes = cacheLimitBytes ?: 1L, - maintainOnInit = false, - enforceSize = cacheLimitBytes != null, - ) + private val cache = FileCache( + cacheRoot, + maxBytes = cacheLimitBytes ?: 1L, + maintainOnInit = false, + enforceSize = cacheLimitBytes != null, + ) private val progress = MutableStateFlow>(emptyMap()) private val generation = MutableStateFlow(0L) private val cacheClearedGeneration = MutableStateFlow(0L) @@ -181,9 +190,7 @@ class MediaRepository( private val telegramPending = PriorityQueue() private val telegramJobs = HashMap() private val pendingConsumerPriority = HashMap() - private val nextConsumerId = - java.util.concurrent.atomic - .AtomicLong() + private val nextConsumerId = java.util.concurrent.atomic.AtomicLong() private val telegramWorkers = mutableListOf() private var telegramSequence = 0L @@ -235,10 +242,9 @@ class MediaRepository( return bytes.takeIf { it.isNotEmpty() } } - private fun cachedInlineThumb(key: String): ByteArray? = - synchronized(inlineThumbs) { - inlineThumbs[key] - } + private fun cachedInlineThumb(key: String): ByteArray? = synchronized(inlineThumbs) { + inlineThumbs[key] + } private fun rememberInlineThumb( key: String, @@ -264,45 +270,31 @@ 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 = - 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 { - 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 { - observed.forEach { (key, entry) -> - keyedGenerations.computeIfPresent(key) { _, current -> - if (current === entry && entry.collectors.decrementAndGet() == 0) null else current - } + 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 { + 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 { + 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)) @@ -313,23 +305,20 @@ class MediaRepository( fun progressBytes(key: String): Long = progress.value[key] ?: 0L - private val queueDelegate = - lazy { - DownloadQueue( - client = httpClientFactory(), - scope = scope, - maxConcurrent = 2, - ).also { it.start() } - } + private val queueDelegate = lazy { + DownloadQueue( + client = httpClientFactory(), + scope = scope, + maxConcurrent = 2, + ).also { it.start() } + } private val queue by queueDelegate fun cachedFile(key: String): File? = cache.get(key) fun acquireCachedFile(key: String): FileCache.Lease? = cache.acquire(key) - private val usedBytes = - java.util.concurrent.atomic - .AtomicLong() + private val usedBytes = java.util.concurrent.atomic.AtomicLong() /** Bytes of cache files a consumer actually opened. Prefetch can compare this with transferred bytes. */ fun usedByteCount(): Long = usedBytes.get() @@ -341,27 +330,25 @@ class MediaRepository( return lease } - suspend fun removeCachedFile(key: String): Boolean = - withContext(Dispatchers.IO) { - cache.remove(key).also { removed -> if (removed) markCached(key) } - } + suspend fun removeCachedFile(key: String): Boolean = withContext(Dispatchers.IO) { + 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() - } + ): 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)) @@ -375,8 +362,11 @@ class MediaRepository( val existing = cache.get(alias) if (existing != null) { if (existing.canonicalFile == file.canonicalFile) return - runCatching { cache.putFile(alias, file, peerId.value, FileCache.KIND_PHOTOS) } - .onSuccess { markCached(alias) } + runCatching { + cache.putFile( + alias, file, peerId.value, FileCache.KIND_PHOTOS + ) + }.onSuccess { markCached(alias) } return } runCatching { @@ -411,15 +401,13 @@ class MediaRepository( fun cacheByChat(): Map = cache.usageByChat() - fun clearChatCache(chatId: Long): Long = - cache.clearChat(chatId).also { - if (it > 0L) markAllCachedFilesChanged() - } + fun clearChatCache(chatId: Long): Long = cache.clearChat(chatId).also { + if (it > 0L) markAllCachedFilesChanged() + } - fun clearKindCache(kind: String): Long = - cache.clearKind(kind).also { - if (it > 0L) markAllCachedFilesChanged() - } + fun clearKindCache(kind: String): Long = cache.clearKind(kind).also { + if (it > 0L) markAllCachedFilesChanged() + } suspend fun ensureLocalMessageMedia( message: Message, @@ -430,13 +418,12 @@ class MediaRepository( suspend fun ensureLocalScheduledMessageMedia( scheduled: ScheduledMessage, priority: Int = MediaPriority.VISIBLE, - ): Outcome = - ensureMessageMedia( - scheduled.message.copy(id = scheduled.mediaMessageId), - priority, - retain = false, - scheduled = true, - ) + ): Outcome = ensureMessageMedia( + scheduled.message.copy(id = scheduled.mediaMessageId), + priority, + retain = false, + scheduled = true, + ) private suspend fun ensureMessageMedia( message: Message, @@ -463,9 +450,8 @@ class MediaRepository( message: Message, priority: Int = MediaPriority.DEFAULT, ): Outcome { - val key = - message.mediaCacheKey?.let { photoDisplayCacheKey(it) } - ?: return Outcome.Err("message has no media key") + val key = message.mediaCacheKey?.let { photoDisplayCacheKey(it) } + ?: return Outcome.Err("message has no media key") return ensureLocalTelegramMedia( key = key, chatId = message.id.chatId, @@ -484,9 +470,8 @@ class MediaRepository( PerfLog.event("stream_prefix", "priority=$priority") val key = streamMediaKey(message) ?: return Outcome.Err("message has no media key") val fetcher = telegramChunkFetcher ?: return Outcome.Err("stream prefetch unavailable") - val limit = - message.fileSize?.takeIf { it > 0 }?.coerceAtMost(STREAM_PREFIX_BYTES) - ?: STREAM_PREFIX_BYTES + val limit = message.fileSize?.takeIf { it > 0 }?.coerceAtMost(STREAM_PREFIX_BYTES) + ?: STREAM_PREFIX_BYTES if (!streamRoot.isDirectory && !streamRoot.mkdirs()) { return Outcome.Err("stream prefix directory") } @@ -501,15 +486,13 @@ class MediaRepository( } val temporary = File.createTempFile("stream-$offset-", ".part", streamRoot) try { - when ( - val result = - fetcher.fetch( - message.id.chatId, - message.id.id, - temporary.absolutePath, - offset, - ) - ) { + when (val result = fetcher.fetchExpected( + message.id.chatId, + message.id.id, + temporary.absolutePath, + offset, + checkNotNull(message.mediaCacheKey), + )) { is Outcome.Err -> { temporary.delete() return last?.let { Outcome.Ok(it) } ?: result @@ -518,33 +501,28 @@ 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") } java.io.RandomAccessFile(fetched, "rw").use { it.fd.sync() } - val file = - try { - cache.putFile( - streamPartKey(key, offset), - fetched, - message.id.chatId.value, - FileCache.KIND_VIDEOS, - ) - } catch (error: Exception) { - return last?.let { Outcome.Ok(it) } ?: Outcome.Err( - error.message ?: "stream prefix publish", - error, - ) - } + val file = try { + cache.putFile( + streamPartKey(key, offset), + fetched, + message.id.chatId.value, + FileCache.KIND_VIDEOS, + ) + } catch (error: Exception) { + return last?.let { Outcome.Ok(it) } ?: Outcome.Err( + error.message ?: "stream prefix publish", + error, + ) + } last = file if (bytes < STREAM_PART) return Outcome.Ok(file) } @@ -588,15 +566,13 @@ class MediaRepository( remaining -= charge val temporary = File.createTempFile("metadata-", ".part", streamRoot) try { - when ( - val result = - fetcher.fetch( - message.id.chatId, - message.id.id, - temporary.absolutePath, - aligned, - ) - ) { + when (val result = fetcher.fetchExpected( + message.id.chatId, + message.id.id, + temporary.absolutePath, + aligned, + checkNotNull(message.mediaCacheKey), + )) { is Outcome.Err -> return null is Outcome.Ok -> if (File(result.value).canonicalFile != temporary.canonicalFile) return null } @@ -621,11 +597,10 @@ class MediaRepository( while (copied < count) { val position = offset + copied val file = part(position) ?: break - val take = - minOf( - (count - copied).toLong(), - file.length() - (position % STREAM_PART), - ).toInt() + val take = minOf( + (count - copied).toLong(), + file.length() - (position % STREAM_PART), + ).toInt() if (take <= 0) break java.io.RandomAccessFile(file, "r").use { input -> input.seek(position % STREAM_PART) @@ -660,12 +635,11 @@ class MediaRepository( suspend fun ensureLocalScheduledMessageThumb( scheduled: ScheduledMessage, priority: Int = MediaPriority.THUMB, - ): Outcome = - ensureMessageThumb( - scheduled.message.copy(id = scheduled.mediaMessageId), - priority, - scheduled = true, - ) + ): Outcome = ensureMessageThumb( + scheduled.message.copy(id = scheduled.mediaMessageId), + priority, + scheduled = true, + ) private suspend fun ensureMessageThumb( message: Message, @@ -696,35 +670,36 @@ 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 } @@ -732,53 +707,50 @@ class MediaRepository( 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, - ) - ) { - is Outcome.Ok -> { - rememberPeerAvatar(peerId, fetched.value) - fetched - } + 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 - } + is Outcome.Err -> { + if (isVideoAvatarKey(cacheKey) || !allowStaleFallback) { + fetched + } else { + cachedAvatar(peerId)?.let { Outcome.Ok(it) } ?: fetched } } + } 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 - val fetched = - ensureLocalTelegramMedia( - key = cacheKey, - chatId = peerId, - messageId = 0, - kind = MediaFetchKind.Full, - priority = MediaPriority.IDLE, - ) + val fetched = ensureLocalTelegramMedia( + key = cacheKey, + chatId = peerId, + messageId = 0, + kind = MediaFetchKind.Full, + priority = priority, + ) if (fetched is Outcome.Ok) rememberPeerAvatar(peerId, fetched.value) } } @@ -811,16 +783,15 @@ class MediaRepository( priority: Int = MediaPriority.DEFAULT, mediaKind: String? = null, consumer: MediaConsumer? = null, - ): Outcome = - ensureLocalTelegramMedia( - key = cacheKey, - chatId = peerId, - messageId = messageId, - kind = if (thumb) MediaFetchKind.Thumb else MediaFetchKind.Full, - mediaKind = mediaKind, - priority = priority, - consumer = consumer, - ) + ): Outcome = ensureLocalTelegramMedia( + key = cacheKey, + chatId = peerId, + messageId = messageId, + kind = if (thumb) MediaFetchKind.Thumb else MediaFetchKind.Full, + mediaKind = mediaKind, + priority = priority, + consumer = consumer, + ) suspend fun ensureCustomEmoji( documentId: Long, @@ -832,36 +803,39 @@ class MediaRepository( 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, + 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") }, ) - }.fold( - onSuccess = { - markCached(key) - Outcome.Ok(it) - }, - onFailure = { Outcome.Err(it.message ?: "cache write failed") }, - ) + } } - } - is Outcome.Err -> { - fetched + is Outcome.Err -> { + fetched + } } + } finally { + dest.delete() } } if (result is Outcome.Err) { @@ -900,99 +874,94 @@ class MediaRepository( } PerfLog.event("cache_miss", kind.name.lowercase()) val queuedAt = PerfLog.nowMs() - val fetched = - enqueueTelegram( - key, - priority, - chatId = chatId.value, - name = name, - totalBytes = totalBytes, - large = kind != MediaFetchKind.Thumb && isLargeDownload(totalBytes), - thumbnail = kind == MediaFetchKind.Thumb, - consumer = consumer, - ) { activePriority -> - val waited = PerfLog.nowMs() - queuedAt - PerfLog.mark("queue_wait:${kind.name.lowercase()}", waited) - cache.get(key)?.let { return@enqueueTelegram Outcome.Ok(it) } - val dest = cache.fileFor(key) - val part = File(dest.parentFile, "${dest.name}.part") - if (isCancelled(key)) { - return@enqueueTelegram Outcome.Err("cancelled") + val fetched = enqueueTelegram( + key, + priority, + chatId = chatId.value, + name = name, + totalBytes = totalBytes, + large = kind != MediaFetchKind.Thumb && isLargeDownload(totalBytes), + thumbnail = kind == MediaFetchKind.Thumb, + consumer = consumer, + ) { activePriority -> + val waited = PerfLog.nowMs() - queuedAt + PerfLog.mark("queue_wait:${kind.name.lowercase()}", waited) + cache.get(key)?.let { return@enqueueTelegram Outcome.Ok(it) } + val dest = cache.fileFor(key) + val part = File(dest.parentFile, "${dest.name}.part") + if (isCancelled(key)) { + return@enqueueTelegram Outcome.Err("cancelled") + } + setProgress(key, maxOf(progressBytes(key), part.length())) + // The native side pipelines parts on one session; this is a cache wrapper. + 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) } - setProgress(key, maxOf(progressBytes(key), part.length())) - // The native side pipelines parts on one session; this is a cache wrapper. - val fetcher = - telegramFetcher - ?: return@enqueueTelegram Outcome.Err("telegram media fetcher not configured") - val destPath = part.absolutePath - progressPaths[destPath] = key - val reservation = - totalBytes - ?.takeIf { it > 0L } - ?.let { - runCatching { - cache.reserve( - key, - it, - optional = false, - ) - }.getOrNull() - } - val fetched = - try { - fetcher.fetchMessageMedia( - chatId = chatId, - messageId = messageId, - destPath = destPath, - kind = kind, - priority = activePriority, + } catch (error: FileCache.AdmissionException) { + return@enqueueTelegram Outcome.Err( + error.message ?: "cache reservation refused" + ) + } + progressPaths[destPath] = key + val fetched = try { + fetcher.fetchExpectedMedia( + chatId = chatId, + messageId = messageId, + destPath = destPath, + kind = kind, + priority = activePriority, + expectedCacheKey = key, + ) + } finally { + progressPaths.remove(destPath) + cancelMarker(key).delete() + cache.notePartial(key, part.length()) + reservation?.close() + } + when (fetched) { + is Outcome.Ok -> { + val file = File(fetched.value) + if (isCancelled(key)) { + part.delete() + file.takeIf { it != part && it.exists() }?.delete() + clearProgress(key) + Outcome.Err("cancelled") + } else if (!file.exists()) { + part.delete() + clearProgress(key) + Outcome.Err("media download produced no file") + } else { + clearProgress(key) + publishDownloadedFile( + key = key, + dest = dest, + source = file, + chatId = storedChatId, + mediaKind = mediaKind, ) - } finally { - progressPaths.remove(destPath) - cancelMarker(key).delete() - cache.notePartial(key, part.length()) - reservation?.close() - } - when (fetched) { - is Outcome.Ok -> { - val file = File(fetched.value) - if (isCancelled(key)) { - part.delete() - file.takeIf { it != part && it.exists() }?.delete() - clearProgress(key) - Outcome.Err("cancelled") - } else if (!file.exists()) { - part.delete() - clearProgress(key) - Outcome.Err("media download produced no file") - } else { - clearProgress(key) - publishDownloadedFile( - key = key, - dest = dest, - source = file, - chatId = storedChatId, - mediaKind = mediaKind, - ) - } } + } - 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 - if (!keepPartial) { - part.delete() - clearProgress(key) - } - fetched + 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 + if (!keepPartial) { + part.delete() + clearProgress(key) } + fetched } } + } if (!retain || fetched !is Outcome.Ok) return fetched return Outcome.Ok(keptDownload(key) ?: fetched.value) } @@ -1025,15 +994,13 @@ class MediaRepository( belowPriority: Int = MediaPriority.USER, keepKeys: Set = emptySet(), ) { - val keys = - telegramMutex.withLock { - val matched = - telegramJobs.values.filter { job -> - job.chatId == chatId.value && job.priority < belowPriority && job.key !in keepKeys && !job.deferred.isCompleted - } - matched.forEach { cancelJobLocked(it) } - matched.map { it.key } + val keys = telegramMutex.withLock { + val matched = telegramJobs.values.filter { job -> + job.chatId == chatId.value && job.priority < belowPriority && job.key !in keepKeys && !job.deferred.isCompleted } + matched.forEach { cancelJobLocked(it) } + matched.map { it.key } + } if (keys.isNotEmpty()) PerfLog.event("cancel_chat", keys.size.toString()) } @@ -1050,11 +1017,10 @@ class MediaRepository( private suspend fun cancelKey(key: String) { telegramMutex.withLock { - val job = - telegramJobs[key] ?: run { - markCancelled(key) - return@withLock - } + val job = telegramJobs[key] ?: run { + markCancelled(key) + return@withLock + } cancelJobLocked(job) } } @@ -1092,13 +1058,10 @@ class MediaRepository( 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) - } + ): 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, @@ -1199,71 +1162,68 @@ class MediaRepository( ): Outcome { cache.get(key)?.let { return Outcome.Ok(it) } val bound = consumer?.takeIf { it.key == key } - val job = - telegramMutex.withLock { - check(!telegramStopped) { "download queue is shut down" } - val existing = telegramJobs[key] - val attached = - if (existing != null) { - if (existing.chatId == null && chatId != null) existing.chatId = chatId - if (large) existing.large = true - if (bound == null) { - if (priority > existing.priority) { - existing.priority = priority - if (!existing.cancelled && !existing.running) { - existing.generation += 1 - existing.sequence = telegramSequence++ - telegramPending += QueuedTelegram(existing) - } else if (existing.running && !existing.cancelled) { - val dest = cache.fileFor(key) - telegramFetcher?.promote( - File( - dest.parentFile, - "${dest.name}.part", - ).absolutePath, - priority, - ) - } - } - if (priority >= MediaPriority.USER) { - trackUserDownloadLocked(key, name, totalBytes) - } - existing.anonymousConsumers++ - existing.consumers++ - WorkOwnership.acquire("media:${existing.key}", existing.priority) - } else { - attachSlotLocked(existing, bound, priority, name, totalBytes) - } - existing - } else { - cancelMarker(key).delete() - val created = - TelegramJob( - key = key, - chatId = chatId, - priority = priority, - sequence = telegramSequence++, - large = large, - thumbnail = thumbnail, - work = work, + val job = telegramMutex.withLock { + check(!telegramStopped) { "download queue is shut down" } + val existing = telegramJobs[key] + val attached = if (existing != null) { + if (existing.chatId == null && chatId != null) existing.chatId = chatId + if (large) existing.large = true + if (bound == null) { + if (priority > existing.priority) { + existing.priority = priority + if (!existing.cancelled && !existing.running) { + existing.generation += 1 + existing.sequence = telegramSequence++ + telegramPending += QueuedTelegram(existing) + } else if (existing.running && !existing.cancelled) { + val dest = cache.fileFor(key) + telegramFetcher?.promote( + File( + dest.parentFile, + "${dest.name}.part", + ).absolutePath, + priority, ) - telegramJobs[key] = created - telegramPending += QueuedTelegram(created) - if (bound == null) { - if (priority >= MediaPriority.USER) { - trackUserDownloadLocked(key, name, totalBytes) - } - created.anonymousConsumers++ - created.consumers++ - WorkOwnership.acquire("media:${created.key}", created.priority) - } else { - attachSlotLocked(created, bound, priority, name, totalBytes) } - created } - ensureTelegramWorkerLocked() - attached + if (priority >= MediaPriority.USER) { + trackUserDownloadLocked(key, name, totalBytes) + } + existing.anonymousConsumers++ + existing.consumers++ + WorkOwnership.acquire("media:${existing.key}", existing.priority) + } else { + attachSlotLocked(existing, bound, priority, name, totalBytes) + } + existing + } else { + cancelMarker(key).delete() + val created = TelegramJob( + key = key, + chatId = chatId, + priority = priority, + sequence = telegramSequence++, + large = large, + thumbnail = thumbnail, + work = work, + ) + telegramJobs[key] = created + telegramPending += QueuedTelegram(created) + if (bound == null) { + if (priority >= MediaPriority.USER) { + trackUserDownloadLocked(key, name, totalBytes) + } + created.anonymousConsumers++ + created.consumers++ + WorkOwnership.acquire("media:${created.key}", created.priority) + } else { + attachSlotLocked(created, bound, priority, name, totalBytes) + } + created } + ensureTelegramWorkerLocked() + attached + } telegramWake.trySend(Unit) thumbWake.trySend(Unit) try { @@ -1309,12 +1269,11 @@ class MediaRepository( val want = fullWorkers + THUMB_WORKERS while (telegramWorkers.size < want) { val thumbnail = telegramWorkers.size >= fullWorkers - telegramWorkers += - scope.launch(Dispatchers.IO) { - while (isActive && !telegramStopped) { - takeNextTelegram(thumbnail)?.let { runTelegram(it) } - } + telegramWorkers += scope.launch(Dispatchers.IO) { + while (isActive && !telegramStopped) { + takeNextTelegram(thumbnail)?.let { runTelegram(it) } } + } } } @@ -1337,14 +1296,13 @@ class MediaRepository( val job = queued.job if (queued.generation != job.generation) continue if (job.cancelled || job.running || job.deferred.isCompleted) continue - val hold = - when { - job.thumbnail != thumbnail -> true - job.large && runningLargeLocked() >= MAX_LARGE_PIPELINES -> true - holdDefault && job.priority < MediaPriority.DISPLAY -> true - holdIdle && job.priority <= MediaPriority.IDLE -> true - else -> false - } + val hold = when { + job.thumbnail != thumbnail -> true + job.large && runningLargeLocked() >= MAX_LARGE_PIPELINES -> true + holdDefault && job.priority < MediaPriority.DISPLAY -> true + holdIdle && job.priority <= MediaPriority.IDLE -> true + else -> false + } if (hold) { skipped += queued continue @@ -1366,39 +1324,36 @@ class MediaRepository( } return } - 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) } - job.runner = runner - try { - runner.start() - runner.await() - } finally { - job.runner = null - } + 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) } + job.runner = runner + try { + runner.start() + runner.await() + } finally { + job.runner = null } - } catch (e: CancellationException) { - if (!currentCoroutineContext().isActive) throw e - Outcome.Err("cancelled") - } catch (e: Exception) { - Outcome.Err(e.message ?: "media download failed", e) } + } catch (e: CancellationException) { + if (!currentCoroutineContext().isActive) throw e + Outcome.Err("cancelled") + } catch (e: Exception) { + Outcome.Err(e.message ?: "media download failed", e) } - val requeued = - telegramMutex.withLock { - if (job.preempted && !job.cancelled && result !is Outcome.Ok) { - requeuePreemptedLocked(job) - true - } else { - false - } + } + val requeued = telegramMutex.withLock { + if (job.preempted && !job.cancelled && result !is Outcome.Ok) { + requeuePreemptedLocked(job) + true + } else { + false } + } if (requeued) { AppLog.api("media", "requeue key=${job.key} pri=${job.priority}") telegramWake.trySend(Unit) @@ -1426,16 +1381,32 @@ 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 { - source.inputStream().use { input -> - dest.outputStream().use { output -> input.copyTo(output) } - } - }.isSuccess - return if (copied) dest else source + 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 -> + staged.outputStream().use { output -> input.copyTo(output) } + } + 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( @@ -1470,35 +1441,32 @@ class MediaRepository( source: File, chatId: Long?, mediaKind: String?, - ): 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") }, + ): 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, ) { 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)) } 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/vendor/mono-rs b/vendor/mono-rs index 5cefccb32..fb4997c02 160000 --- a/vendor/mono-rs +++ b/vendor/mono-rs @@ -1 +1 @@ -Subproject commit 5cefccb327584f6fb0094e3fa617a976d7892fc2 +Subproject commit fb4997c02b16aab261e53e9f8d5c17fd805e23f9 From b2552f1a053b23e54e16e3f76b6267ccf7800195 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:33:57 +0300 Subject: [PATCH 9/9] fix(media): gif and docs download --- .../feature/dialog/PickerMediaPreloader.kt | 48 +++++++++++++------ vendor/mono-rs | 2 +- 2 files changed, 34 insertions(+), 16 deletions(-) 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/vendor/mono-rs b/vendor/mono-rs index fb4997c02..ccb2ea460 160000 --- a/vendor/mono-rs +++ b/vendor/mono-rs @@ -1 +1 @@ -Subproject commit fb4997c02b16aab261e53e9f8d5c17fd805e23f9 +Subproject commit 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 O2l7Rtn{8gK#QzWDv5Bq# delta 375281 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

  • (?P-)+LS=&e0si2!7l{S`LP>1M^(i&Ug0&| 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